From 37d418ba51b68269fe54a5dd1d7ff34a661a034a Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:21:07 +0700 Subject: [PATCH 01/22] Improve YouTube quota tracking and search caching Quota management: - track quota exhaustion until YouTube's daily reset in Pacific time - extend the search cache TTL Search normalization: - normalize search cache keys consistently - preserve combining marks in search queries URL parsing: - improve handling of scheme-less YouTube links - support additional valid playlist ID formats --- src/youtube/cache.ts | 74 ++++++++++++++------------------------ src/youtube/client.ts | 12 ++----- src/youtube/index.ts | 27 +++++++------- src/youtube/url.ts | 83 ++++++++++++++++--------------------------- src/youtube/utils.ts | 2 +- 5 files changed, 75 insertions(+), 123 deletions(-) diff --git a/src/youtube/cache.ts b/src/youtube/cache.ts index 9953a52..a064866 100644 --- a/src/youtube/cache.ts +++ b/src/youtube/cache.ts @@ -1,15 +1,8 @@ -import { dataPath, createFileStore } from '../persist.js' -import { createLogger, describeError } from '../logger.js' import { Song, FilterFailureReason } from '../types.js' -const log = createLogger('CACHE') - export const CACHE_LIMITS = { VIDEO_CACHE_TTL: 10 * 60 * 1000, - SEARCH_CACHE_TTL: 3600 * 1000, // 1 hour - // Each search call costs 100 quota units of the default 10,000/day project quota; - // 90 searches/day (9,000 units) leaves headroom for other endpoints (1 unit each) - MAX_DAILY_SEARCHES: 90 + SEARCH_CACHE_TTL: 24 * 3600 * 1000 // 24 hours } const MAX_ENTRIES = 200 @@ -19,6 +12,31 @@ type Entry = { value: T; expiresAt: number } const searches = new Map>() const videos = new Map>() +// YouTube resets daily API quota at midnight Pacific Time — do not change this timezone +function getQuotaDate(): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).format(new Date()) +} + +let quotaExhaustedDate: string | null = null + +export function markQuotaExhausted(): void { + quotaExhaustedDate = getQuotaDate() +} + +export function isQuotaExhausted(): boolean { + const today = getQuotaDate() + if (quotaExhaustedDate !== today) { + quotaExhaustedDate = null + return false + } + return true +} + function read(map: Map>, key: string): T | undefined { const entry = map.get(key) if (!entry) return undefined @@ -52,43 +70,3 @@ export function getVideoCache(key: string): VideoCacheResult | undefined { export function setVideoCache(key: string, result: VideoCacheResult): void { write(videos, key, result, CACHE_LIMITS.VIDEO_CACHE_TTL) } - -// quota: the only thing that must survive a restart (and a cache cleanup) -type Quota = { date: string; searches: number } -const quotaStore = createFileStore(() => dataPath('youtube-quota.json')) -let quota: Quota | null = null - -// YouTube resets daily API quota at midnight Pacific Time — do not change this timezone -function getQuotaDate(): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'America/Los_Angeles', - year: 'numeric', - month: '2-digit', - day: '2-digit' - }).format(new Date()) -} - -function currentQuota(): Quota { - const today = getQuotaDate() - quota ??= quotaStore.load({ date: today, searches: 0 }) - if (quota.date !== today) quota = { date: today, searches: 0 } - return quota -} - -const saveQuota = () => quotaStore.scheduleSave(() => currentQuota(), (error) => log.error(`Failed to save quota: ${describeError(error)}`)) - -export function reserveSearchQuota(): boolean { - const current = currentQuota() - if (current.searches >= CACHE_LIMITS.MAX_DAILY_SEARCHES) return false - current.searches++ - saveQuota() - return true -} - -export function releaseSearchQuota(): void { - const current = currentQuota() - if (current.searches > 0) current.searches-- - saveQuota() -} - -export const getSearchesToday = (): number => currentQuota().searches diff --git a/src/youtube/client.ts b/src/youtube/client.ts index 2bd70f4..fce667f 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -129,16 +129,11 @@ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]) const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 500 -// The request never reached YouTube (no key, no network), so YouTube cannot have charged quota for it -export class RequestNotSentError extends AppError {} - -const isTimeout = (error: unknown): boolean => error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError') - export async function youtube(path: string, params: Record, { attempts = MAX_ATTEMPTS }: { attempts?: number } = {}): Promise { const { youtubeApiKey } = getSecrets() if (!youtubeApiKey) { - throw new RequestNotSentError('NO_API_KEY', 'YouTube API key is not configured, add it in the control panel') + throw new AppError('NO_API_KEY', 'YouTube API key is not configured, add it in the control panel') } const url = new URL(`https://www.googleapis.com/youtube/v3/${path}`) @@ -155,8 +150,7 @@ export async function youtube(path: string, params: Record, { } catch (error) { if (attempt >= attempts) { const message = `YouTube API is unreachable: ${describeError(error)}` - // a timeout may have reached YouTube, anything else (DNS, no route, refused) did not - throw isTimeout(error) ? new AppError('YOUTUBE_ERROR', message) : new RequestNotSentError('YOUTUBE_ERROR', message) + throw new AppError('YOUTUBE_ERROR', message) } await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * attempt)) continue @@ -174,7 +168,7 @@ export async function youtube(path: string, params: Record, { const reason = data.error?.errors?.[0]?.reason if (reason === 'quotaExceeded') { - throw new AppError('YOUTUBE_QUOTA', 'YouTube API quota exceeded') + throw new AppError('YOUTUBE_QUOTA', 'YouTube API quota exceeded. Quota resets at midnight Pacific Time') } throw new AppError('YOUTUBE_ERROR', data.error?.message || `YouTube API error ${response.status}`) diff --git a/src/youtube/index.ts b/src/youtube/index.ts index c737677..5b6f949 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -8,12 +8,11 @@ import { fetchPlaylistMeta, PlaylistMeta, MUSIC_CATEGORY_ID, - RequestNotSentError, PlaylistFetchResult, fetchVideos } from './client.js' import { normalize } from './utils.js' -import { getSearchCache, setSearchCache, getVideoCache, setVideoCache, reserveSearchQuota, releaseSearchQuota, CACHE_LIMITS } from './cache.js' +import { getSearchCache, setSearchCache, getVideoCache, setVideoCache, isQuotaExhausted, markQuotaExhausted } from './cache.js' import { VideoItem, SearchItem, PlaylistItem } from './types.js' import { getConfig } from '../config.js' import { createLogger, describeError } from '../logger.js' @@ -95,7 +94,8 @@ export async function searchSongs(query: string, bypassFilters = false): Promise const cached = bypassFilters ? undefined : getSearchCache(cacheKey) if (cached !== undefined) return cached - return dedupInFlight(pendingSearches, cacheKey, () => performSearch(normalizedQuery, bypassFilters)) + const trimmedQuery = query.trim() + return dedupInFlight(pendingSearches, cacheKey, () => performSearch(trimmedQuery, bypassFilters)) } function mapValidSongs(videos: VideoItem[], bypassFilters = false): Song[] { @@ -106,10 +106,9 @@ function mapValidSongs(videos: VideoItem[], bypassFilters = false): Song[] { } async function performSearch(query: string, bypassFilters: boolean): Promise { - // after the cache and the in-flight check, so only a search that really goes out costs quota - if (!reserveSearchQuota()) { - log.warn(`Daily search limit reached: ${CACHE_LIMITS.MAX_DAILY_SEARCHES}`) - throw new AppError('YOUTUBE_QUOTA', 'daily YouTube search quota exceeded, use a direct link instead') + if (isQuotaExhausted()) { + log.warn('YouTube quota exhausted for today') + throw new AppError('YOUTUBE_QUOTA', 'YouTube API quota exceeded. Quota resets at midnight Pacific Time') } let search: { items: SearchItem[] } @@ -131,14 +130,15 @@ async function performSearch(query: string, bypassFilters: boolean): Promise item.id?.videoId).filter((id): id is string => Boolean(id)) - const cacheKey = bypassFilters ? `raw:${query}` : `${filtersVersion()}|${query}` + const normalizedQuery = normalize(query) + const cacheKey = bypassFilters ? `raw:${normalizedQuery}` : `${filtersVersion()}|${normalizedQuery}` if (!ids.length) { if (!bypassFilters) setSearchCache(cacheKey, []) @@ -154,8 +154,9 @@ async function performSearch(query: string, bypassFilters: boolean): Promise boolean = () => true): Song | null { const selected = songs.find(isAvailable) ?? songs[0] diff --git a/src/youtube/url.ts b/src/youtube/url.ts index 3f6eff9..d647dab 100644 --- a/src/youtube/url.ts +++ b/src/youtube/url.ts @@ -1,68 +1,47 @@ +// Only the kinds the playlistItems API can read: user playlists (PL), channel +// uploads (UU), auto-generated albums (OL). Mixes (RD), liked (LL) and +// favourites (FL) answer with an error there. +const PLAYLIST_ID = /^(?:PL|UU|OL)[\w-]+$/ +const VIDEO_KINDS = new Set(['shorts', 'embed', 'v', 'live']) +const HOSTS = new Set(['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'music.youtube.com']) +// YouTube hosts written without a scheme: "youtu.be/ID", "www.youtube.com/watch?v=ID", … +const SCHEMELESS = /^(?:www\.|m\.|music\.)?(?:youtube(?:-nocookie)?\.com|youtu\.be)\// + export function isValidVideoId(value: unknown): value is string { - return typeof value === 'string' && /^[a-zA-Z0-9_-]{11}$/.test(value) + return typeof value === 'string' && /^[\w-]{11}$/.test(value) } -const VIDEO_PATH_KINDS = new Set(['shorts', 'embed', 'v', 'live']) - -export function parseYouTubeUrl(input: string): { isYouTube: boolean; videoId: string | null } { - let url: URL +export function isValidPlaylistId(value: unknown): value is string { + return typeof value === 'string' && PLAYLIST_ID.test(value) +} +/** Parses input as a URL, prepending https:// to scheme-less YouTube hosts. Null if not a URL. */ +function toUrl(input: string): URL | null { + const s = input.trim() try { - url = new URL(input) + return new URL(SCHEMELESS.test(s.toLowerCase()) ? `https://${s}` : s) } catch { - return { isYouTube: false, videoId: null } + return null } +} - const hostname = url.hostname - .toLowerCase() - .replace(/^www\./, '') - .replace(/^m\./, '') - - const isYouTube = hostname === 'youtube.com' || hostname === 'youtube-nocookie.com' || hostname === 'youtu.be' || hostname === 'music.youtube.com' - - if (!isYouTube) return { isYouTube: false, videoId: null } - - if (hostname === 'youtu.be') { - const id = url.pathname.split('/').filter(Boolean)[0] - return { isYouTube: true, videoId: isValidVideoId(id) ? id : null } - } +export function parseYouTubeUrl(input: string): { isYouTube: boolean; videoId: string | null } { + const url = toUrl(input) + if (!url) return { isYouTube: false, videoId: null } - if (url.pathname === '/watch') { - const id = url.searchParams.get('v') - return { isYouTube: true, videoId: isValidVideoId(id) ? id : null } - } + const host = url.hostname.replace(/^(?:www\.|m\.)+/, '') + if (!HOSTS.has(host)) return { isYouTube: false, videoId: null } - // /shorts/, /embed/, /v/, /live/: the whole segment has to be an id, not just its first 11 characters + // youtu.be/ · /watch?v= · /shorts|embed|v|live/ — segment must be the whole id const [kind, segment] = url.pathname.split('/').filter(Boolean) - const id = kind && VIDEO_PATH_KINDS.has(kind) ? segment : undefined - return { isYouTube: true, videoId: isValidVideoId(id) ? id : null } -} - -// Only the kinds the playlistItems API can read: user playlists (PL), channel uploads (UU), auto-generated albums (OL). -// Mixes (RD), liked videos (LL) and favourites (FL) answer with an error there -const PLAYLIST_ID_PATTERN = /^(PL|UU|OL)[A-Za-z0-9_-]+$/ + const id = host === 'youtu.be' ? kind : kind === 'watch' ? url.searchParams.get('v') : VIDEO_KINDS.has(kind) ? segment : null -export function isValidPlaylistId(value: unknown): value is string { - return typeof value === 'string' && PLAYLIST_ID_PATTERN.test(value) + return { isYouTube: true, videoId: isValidVideoId(id) ? id : null } } export function parsePlaylistId(input: string): string | null { - const trimmed = input.trim() - - if (!trimmed) { - return null - } - - try { - const url = new URL(trimmed) - const listParam = url.searchParams.get('list') - if (listParam) { - return PLAYLIST_ID_PATTERN.test(listParam) ? listParam : null - } - return null - } catch { - // not a URL - assume it's already a bare ID - } - - return PLAYLIST_ID_PATTERN.test(trimmed) ? trimmed : null + const s = input.trim() + const list = toUrl(s)?.searchParams.get('list') + // if s was a URL, it can't match PLAYLIST_ID, so the fallback only fires for bare IDs + return isValidPlaylistId(list) ? list : isValidPlaylistId(s) ? s : null } diff --git a/src/youtube/utils.ts b/src/youtube/utils.ts index 9c3ab92..1e698e8 100644 --- a/src/youtube/utils.ts +++ b/src/youtube/utils.ts @@ -1,7 +1,7 @@ export function normalize(value: string): string { return value .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ') .replace(/\s+/g, ' ') .trim() } From 9bb70a714e65b75726814b7ad2212b9b5ed432d1 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:03:17 +0700 Subject: [PATCH 02/22] Harden Twitch authentication and error handling Error handling: - normalize Twitch API and network errors - retry user-info lookups during authorization Token persistence: - persist refreshed tokens immediately - prevent cancelled authorization and stale token refreshes from overwriting newer login state --- public/locales/en.json | 4 ++- public/locales/ru.json | 4 ++- src/config-helper.ts | 10 +++++- src/http.ts | 2 ++ src/integrations/twitch/chat.ts | 3 +- src/integrations/twitch/client.ts | 41 +++++++++++++----------- src/integrations/twitch/error.ts | 12 +++++++ src/integrations/twitch/eventsub.ts | 3 +- src/integrations/twitch/index.ts | 48 ++++++++++++++++++++++------ src/integrations/twitch/oauth.ts | 49 ++++++++++++++++++----------- src/persist.ts | 9 ++++++ src/secrets.ts | 10 +++++- src/types.ts | 2 ++ 13 files changed, 144 insertions(+), 53 deletions(-) create mode 100644 src/integrations/twitch/error.ts diff --git a/public/locales/en.json b/public/locales/en.json index 7c1b619..46ce79c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -270,7 +270,9 @@ "twitchOAuthError": "Failed to complete Twitch authorization", "twitchAuthExpired": "Twitch authorization expired, try connecting again", "twitchNotConnected": "Twitch account is not connected", - "twitchRefreshError": "Failed to refresh Twitch connection" + "twitchRefreshError": "Failed to refresh Twitch connection", + "twitchNetworkError": "No connection to Twitch", + "twitchUnknownError": "Unknown Twitch error" } }, "toast": { diff --git a/public/locales/ru.json b/public/locales/ru.json index ab318b9..cdec321 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -270,7 +270,9 @@ "twitchOAuthError": "Не удалось завершить авторизацию Twitch", "twitchAuthExpired": "Время авторизации Twitch истекло, попробуйте подключиться снова", "twitchNotConnected": "Аккаунт Twitch не подключён", - "twitchRefreshError": "Не удалось обновить подключение Twitch" + "twitchRefreshError": "Не удалось обновить подключение Twitch", + "twitchNetworkError": "Нет связи с Twitch", + "twitchUnknownError": "Неизвестная ошибка Twitch" } }, "toast": { diff --git a/src/config-helper.ts b/src/config-helper.ts index 9191d1c..8154258 100644 --- a/src/config-helper.ts +++ b/src/config-helper.ts @@ -111,5 +111,13 @@ export function createConfigModule(options: { save() } - return { getConfig: (): T => structuredClone(current()), validateConfigUpdates, updateConfig, restoreConfig } + return { + getConfig: (): T => structuredClone(current()), + validateConfigUpdates, + updateConfig, + restoreConfig, + saveNow: async (onError: (error: unknown) => void) => { + await store.saveNow(() => current(), onError) + } + } } diff --git a/src/http.ts b/src/http.ts index 488d8a4..0dc2319 100644 --- a/src/http.ts +++ b/src/http.ts @@ -61,6 +61,8 @@ const STATUS_BY_CODE: Record = { IS_LIVE: 404, IS_SHORT: 404, INVALID_INPUT: 400, + TWITCH_NETWORK_ERROR: 502, + TWITCH_UNKNOWN_ERROR: 500 } export type ErrorInfo = { code: string; status: number; message: string; params?: Record } diff --git a/src/integrations/twitch/chat.ts b/src/integrations/twitch/chat.ts index da4cabc..5243bf3 100644 --- a/src/integrations/twitch/chat.ts +++ b/src/integrations/twitch/chat.ts @@ -2,6 +2,7 @@ import type WebSocket from 'ws' import { TwitchOAuth } from './oauth.js' import { ReconnectingSocket } from './socket.js' import type { TwitchChatMessage } from './types.js' +import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' const log = createLogger('TWITCH CHAT') @@ -104,7 +105,7 @@ export class TwitchChat extends ReconnectingSocket { protected async beforeOpen(): Promise { const accessToken = await this.config.oauth.getValidAccessToken() - if (!accessToken) throw new Error('Twitch account is not connected, cannot open chat connection') + if (!accessToken) throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected, cannot open chat connection') this.accessToken = accessToken } diff --git a/src/integrations/twitch/client.ts b/src/integrations/twitch/client.ts index fdb8dda..8e9d539 100644 --- a/src/integrations/twitch/client.ts +++ b/src/integrations/twitch/client.ts @@ -12,6 +12,7 @@ import type { import { TwitchOAuth } from './oauth.js' import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' +import { normalizeTwitchError } from './error.js' const log = createLogger('TWITCH CLIENT') @@ -29,8 +30,6 @@ export class TwitchClient { } private async makeAuthenticatedRequest(url: string, options: RequestInit = {}): Promise { - if (!this.oauth.getConfig().clientId) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client ID not configured') - const accessToken = await this.oauth.getValidAccessToken() if (!accessToken) { throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected', this.refreshMayWorkLater() ? { transient: 1 } : undefined) @@ -46,13 +45,13 @@ export class TwitchClient { try { refreshedToken = await this.oauth.refreshAccessToken() } catch (error) { - const reason = describeError(error) - log.error(`Token refresh failed: ${reason}`) - throw new AppError( - 'TWITCH_REFRESH_ERROR', - `Authentication failed - please reconnect your Twitch account (${reason})`, - this.refreshMayWorkLater() ? { transient: 1 } : undefined - ) + const normalized = normalizeTwitchError(error) + log.error(`Token refresh failed: ${describeError(normalized)}`) + // Preserve transient flag if already present, otherwise determine based on refreshMayWorkLater + if (!normalized.params?.transient && this.refreshMayWorkLater()) { + normalized.params = { ...normalized.params, transient: 1 } + } + throw normalized } // an error of the retried request itself is its own error, not a failed login @@ -60,15 +59,19 @@ export class TwitchClient { } private async request(url: string, options: RequestInit, accessToken: string): Promise { - return fetch(url, { - ...options, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - headers: { - ...options.headers, - Authorization: `Bearer ${accessToken}`, - 'Client-Id': this.oauth.getConfig().clientId - } - }) + try { + return await fetch(url, { + ...options, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + ...options.headers, + Authorization: `Bearer ${accessToken}`, + 'Client-Id': this.oauth.getConfig().clientId + } + }) + } catch (error) { + throw normalizeTwitchError(error) + } } private async handleResponse(response: Response): Promise { @@ -83,7 +86,7 @@ export class TwitchClient { // Ignore invalid/non-JSON responses. } - throw new AppError('TWITCH_API_ERROR', `Twitch API error: ${message}`, { status: response.status }) + throw new AppError('TWITCH_API_ERROR', message, { status: response.status }) } // a login exists and Twitch has not refused it: a failed refresh was a passing problem diff --git a/src/integrations/twitch/error.ts b/src/integrations/twitch/error.ts new file mode 100644 index 0000000..c6089ba --- /dev/null +++ b/src/integrations/twitch/error.ts @@ -0,0 +1,12 @@ +import { AppError } from '../../types.js' + +export function normalizeTwitchError(error: unknown): AppError { + if (error instanceof AppError) return error + + if (error instanceof Error) { + // Network errors (no connection, timeout, DNS failure) + return new AppError('TWITCH_NETWORK_ERROR', error.message, { transient: 1 }) + } + + return new AppError('TWITCH_UNKNOWN_ERROR', String(error)) +} diff --git a/src/integrations/twitch/eventsub.ts b/src/integrations/twitch/eventsub.ts index 0ed32c1..835b584 100644 --- a/src/integrations/twitch/eventsub.ts +++ b/src/integrations/twitch/eventsub.ts @@ -2,6 +2,7 @@ import { CHANNEL_POINTS_REDEMPTION, type TwitchClient } from './client.js' import { ReconnectingSocket, closeQuietly } from './socket.js' import type { EventSubMessage, TwitchChannelPointsRedemption } from './types.js' import { createLogger, describeError } from '../../logger.js' +import { normalizeTwitchError } from './error.js' const log = createLogger('TWITCH EVENTSUB') @@ -83,7 +84,7 @@ export class TwitchEventSub extends ReconnectingSocket { this.markReady() this.watchKeepalive(message) } catch (error) { - const normalized = error instanceof Error ? error : new Error(String(error)) + const normalized = normalizeTwitchError(error) this.abort(normalized) throw normalized } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 8e9fc77..ff7c6a9 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -28,6 +28,7 @@ import { AppError } from '../../types.js' import type { FailureReason, QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { emit, notify } from '../../state-events.js' +import { normalizeTwitchError } from './error.js' const log = createLogger('TWITCH') const chatLog = createLogger('TWITCH CHAT') @@ -77,11 +78,16 @@ export function initializeTwitchIntegration(config: Partial = return } + if (!config.clientId) { + log.log('Twitch client ID not configured, integration will not be functional') + return + } + oauth = new TwitchOAuth({ - clientId: config.clientId || '', + clientId: config.clientId, scopes: config.scopes, onTokenUpdated: (tokenData) => { - updateTwitchOAuthState({ tokenData }) + updateTwitchOAuthState({ tokenData }, true) }, onAuthStateChange: () => emit('twitch') }) @@ -200,7 +206,8 @@ async function connectOrRetry(socket: TwitchEventSub | TwitchChat, name: string, await socket.connect() logger.log(`${name} connected`) } catch (error) { - logger.error(`Failed to start ${name}, will keep retrying: ${describeError(error)}`) + const normalized = normalizeTwitchError(error) + logger.error(`Failed to start ${name}, will keep retrying: ${describeError(normalized)}`) socket.retryInBackground() } } @@ -359,18 +366,40 @@ export async function startDeviceAuthorization(): Promise setTimeout(resolve, 1000)) + } + } + } + + if (!fetchedUserInfo) { + log.error(`getUserInfo failed after 3 attempts: ${describeError(lastError)}`) + // Rollback tokens on failure + await oauth?.revokeTokens() + oauth?.clearTokenData() + throw new AppError('TWITCH_AUTH_ERROR', 'Failed to fetch user info after successful login. Please try connecting again.') + } updateTwitchOAuthState({ - userInfo, + userInfo: fetchedUserInfo, connectedAt: Date.now() }) await startEventSub() await startChat() - log.log(`Twitch account connected: ${userInfo.displayName}`) - return userInfo + log.log(`Twitch account connected: ${fetchedUserInfo.displayName}`) + return fetchedUserInfo }) // nobody awaits this promise: a refusal, an expired code or a cancel is logged here instead of becoming an unhandled rejection .catch((error) => { @@ -540,9 +569,8 @@ export async function refreshConnection(): Promise { log.log('Twitch connection refreshed') return userInfo } catch (error) { - const reason = describeError(error) - log.error(`Failed to refresh connection: ${reason}`) - throw new AppError('TWITCH_REFRESH_ERROR', reason) + log.error(`Failed to refresh connection: ${describeError(error)}`) + throw error } } diff --git a/src/integrations/twitch/oauth.ts b/src/integrations/twitch/oauth.ts index c73bd48..bfd4681 100644 --- a/src/integrations/twitch/oauth.ts +++ b/src/integrations/twitch/oauth.ts @@ -1,5 +1,6 @@ import { createLogger, describeError } from '../../logger.js' import { AppError } from '../../types.js' +import { normalizeTwitchError } from './error.js' import type { TwitchAuthConfig, TwitchDeviceCodeResponse, @@ -27,9 +28,10 @@ function postForm(url: string, params: URLSearchParams): Promise { }) } -async function oauthError(response: Response, fallback: string): Promise { +async function oauthError(response: Response, fallback: string): Promise { const body = (await response.json().catch(() => ({}))) as TwitchErrorResponse - return new Error(`${fallback}: ${body.message || `HTTP ${response.status}`}`) + const message = body.message || fallback + return new AppError('TWITCH_API_ERROR', message, { status: response.status }) } export class TwitchOAuth { @@ -37,10 +39,15 @@ export class TwitchOAuth { private tokenData: TwitchTokenData | null = null private refreshPromise: Promise | null = null private authFailed = false + private tokenGeneration = 0 private onTokenUpdated?: (tokenData: TwitchTokenData) => void private onAuthStateChange?: () => void constructor(config: TwitchOAuthOptions) { + if (!config.clientId) { + throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client ID not configured') + } + this.config = { clientId: config.clientId, scopes: config.scopes || DEFAULT_SCOPES @@ -48,8 +55,6 @@ export class TwitchOAuth { this.onTokenUpdated = config.onTokenUpdated this.onAuthStateChange = config.onAuthStateChange - - if (!this.config.clientId) log.log('Twitch client ID not configured') } getConfig(): TwitchAuthConfig { @@ -60,8 +65,6 @@ export class TwitchOAuth { } async requestDeviceCode(): Promise { - if (!this.config.clientId) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client ID not configured') - const params = new URLSearchParams({ client_id: this.config.clientId, scopes: this.config.scopes.join(' ') @@ -77,7 +80,7 @@ export class TwitchOAuth { return data } catch (error) { log.error(`Device code request failed: ${describeError(error)}`) - throw error + throw normalizeTwitchError(error) } } @@ -85,14 +88,14 @@ export class TwitchOAuth { // the tokens Twitch gave it in the meantime are revoked instead of replacing those of the account that is connected async pollForToken(deviceCode: string, interval: number, expiresIn: number, isWanted: () => boolean = () => true): Promise { if (!deviceCode) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch device code is required') - if (!this.config.clientId) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client ID not configured') const deadline = Date.now() + expiresIn * 1000 let pollInterval = interval * 1000 + const currentGeneration = this.tokenGeneration while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, pollInterval)) - if (!isWanted()) throw new Error('Device authorization was cancelled') + if (!isWanted()) throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') const params = new URLSearchParams({ client_id: this.config.clientId, @@ -115,10 +118,10 @@ export class TwitchOAuth { if (!isWanted()) { // the code was entered after all, but nobody waits for it any more: the account must not change await this.revoke([data.access_token, data.refresh_token]) - throw new Error('Device authorization was cancelled') + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') } - const tokenData = this.store(data) + const tokenData = this.store(data, currentGeneration) log.log('Device authorization successful') return tokenData @@ -134,7 +137,7 @@ export class TwitchOAuth { if (error.message === 'access_denied') throw new AppError('TWITCH_AUTH_ERROR', 'Twitch authorization was denied') if (error.message === 'expired_token') throw new AppError('TWITCH_AUTH_ERROR', 'Twitch device code expired') - throw new Error(error.message || 'Device authorization failed') + throw new AppError('TWITCH_AUTH_ERROR', error.message || 'Device authorization failed') } throw new AppError('TWITCH_AUTH_ERROR', 'Twitch device code expired') @@ -143,10 +146,10 @@ export class TwitchOAuth { async refreshAccessToken(): Promise { if (this.refreshPromise) return this.refreshPromise if (this.authFailed) throw new AppError('TWITCH_REFRESH_ERROR', 'Twitch no longer accepts the saved login, connect the account again') - if (!this.config.clientId) throw new AppError('TWITCH_REFRESH_ERROR', 'Twitch client ID not configured') if (!this.tokenData?.refreshToken) throw new AppError('TWITCH_REFRESH_ERROR', 'No refresh token available') - this.refreshPromise = this.performRefresh() + const currentGeneration = this.tokenGeneration + this.refreshPromise = this.performRefresh(currentGeneration) try { return await this.refreshPromise @@ -189,6 +192,7 @@ export class TwitchOAuth { clearTokenData(): void { this.tokenData = null this.refreshPromise = null + this.tokenGeneration++ this.setAuthFailed(false) log.log('Token data cleared') } @@ -239,7 +243,7 @@ export class TwitchOAuth { return Date.now() >= this.tokenData.expiresAt - REFRESH_BUFFER_MS } - private async performRefresh(): Promise { + private async performRefresh(expectedGeneration: number): Promise { const refreshToken = this.tokenData?.refreshToken if (!refreshToken) throw new AppError('TWITCH_REFRESH_ERROR', 'No refresh token available') @@ -259,16 +263,25 @@ export class TwitchOAuth { throw await oauthError(response, 'Token refresh failed') } - const tokenData = this.store((await response.json()) as TwitchTokenResponse) + const tokenData = this.store((await response.json()) as TwitchTokenResponse, expectedGeneration) log.log('Token refresh successful') return tokenData } catch (error) { + if (error instanceof AppError) { + log.error(describeError(error)) + throw error + } + // Network errors become TWITCH_REFRESH_ERROR log.error(describeError(error)) - throw error + throw new AppError('TWITCH_REFRESH_ERROR', `Token refresh failed: ${describeError(error)}`) } } - private store(data: TwitchTokenResponse): TwitchTokenData { + private store(data: TwitchTokenResponse, expectedGeneration: number): TwitchTokenData { + if (expectedGeneration !== this.tokenGeneration) { + throw new AppError('TWITCH_REFRESH_ERROR', 'Login was cancelled during token refresh') + } + this.tokenData = { accessToken: data.access_token, refreshToken: data.refresh_token, diff --git a/src/persist.ts b/src/persist.ts index 45b029f..9fa85ab 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -75,6 +75,14 @@ export function createFileStore(location: string | (() => string)) { await rename(tmpPath, filePath) } + async function saveNow(getData: () => T, onError: (error: unknown) => void): Promise { + try { + await persistNow(getData) + } catch (error) { + onError(error) + } + } + function enqueue(job: SaveJob): void { saveChain = saveChain.then(() => persistNow(job.getData)).catch(job.onError) } @@ -111,6 +119,7 @@ export function createFileStore(location: string | (() => string)) { return { load, scheduleSave, + saveNow, flush, path: resolvePath } diff --git a/src/secrets.ts b/src/secrets.ts index bd7d942..23d7726 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -3,6 +3,9 @@ import { createConfigModule, field, type FieldRule, type Schema } from './config import { emit } from './state-events.js' import { getTwitchClientId } from './env.js' import { AppError, type TwitchAuthorization, type TwitchSecrets, type TwitchTokenData, type TwitchUserInfo } from './types.js' +import { createLogger, describeError } from './logger.js' + +const log = createLogger('SECRETS') export type Secrets = { youtubeApiKey: string @@ -75,12 +78,17 @@ export function updateSecrets(updates: SecretsUpdates): Secrets { return getSecrets() } -export function updateTwitchOAuthState(updates: Partial): void { +export function updateTwitchOAuthState(updates: Partial, immediate = false): void { if (Object.keys(updates).length === 0) return apply({ twitch: updates }) // the account (or its token) changed: the panel reads the connection again emit('twitch') + + // Save token data immediately to avoid losing it on process exit + if (immediate && updates.tokenData) { + secrets.saveNow((error) => log.error(`Failed to save secrets: ${describeError(error)}`)) + } } export function clearTwitchOAuthState(): void { diff --git a/src/types.ts b/src/types.ts index 38519b7..579ece6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,8 @@ export type AppErrorCode = | 'TWITCH_NOT_CONNECTED' | 'TWITCH_API_ERROR' | 'INVALID_INPUT' + | 'TWITCH_NETWORK_ERROR' + | 'TWITCH_UNKNOWN_ERROR' | FilterFailureReason // A reason a song request or an already-queued track's playback failed, with whatever the reason needs From 868dd8505b9b4cbcfed0a901eedeb5f403e2e0b2 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:33:15 +0700 Subject: [PATCH 03/22] Harden localization and Twitch chat messaging Localization: - return translation keys when translations are missing Chat messages: - pass locale and player state explicitly to shared message builders - wait for Twitch chat reconnection before sending replies - surface failed sends caused by disconnections as errors --- src/chat-replies.ts | 67 +++++++++++++------------------- src/http.ts | 4 +- src/i18n.ts | 11 ++---- src/integrations/twitch/chat.ts | 3 +- src/integrations/twitch/index.ts | 43 +++++++++++++++----- src/routes/chat.ts | 10 ++++- src/routes/player.ts | 15 ++++--- src/routes/queue.ts | 11 ++---- 8 files changed, 87 insertions(+), 77 deletions(-) diff --git a/src/chat-replies.ts b/src/chat-replies.ts index 8be1c88..8e4918e 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -1,6 +1,4 @@ -import { translateWithFallback, t } from './i18n.js' -import { getSettings } from './settings.js' -import { getState } from './player.js' +import { t } from './i18n.js' import type { PlayerState, FailureReason } from './types.js' const truncate = (s: string, max = 40) => { @@ -9,35 +7,25 @@ const truncate = (s: string, max = 40) => { } // "Now playing: X (requested by Y)" / "Nothing is playing right now" - shared by the HTTP chat endpoint and the Twitch chat module -export function buildNowPlayingMessage(): string { - const state = getState() - +export function buildNowPlayingMessage(state: PlayerState, locale: string): string { if (!state.current) { - return translateWithFallback('chat.nothingPlaying', undefined, 'Nothing is playing right now') + return t(locale, 'chat.nothingPlaying') } - const base = translateWithFallback( - 'chat.nowPlayingWithRequester', - { title: state.current.title, requestedBy: state.current.requestedBy }, - `Now playing: ${state.current.title} (requested by ${state.current.requestedBy})` - ) - const pausedSuffix = state.isPaused ? translateWithFallback('chat.pausedSuffix', undefined, ' (paused)') : '' + const base = t(locale, 'chat.nowPlayingWithRequester', { title: state.current.title, requestedBy: state.current.requestedBy }) + const pausedSuffix = state.isPaused ? t(locale, 'chat.pausedSuffix') : '' return base + pausedSuffix } // "Track skipped. Now playing: X" / "Track skipped" - shared by the HTTP player route and the Twitch chat module -export function buildSkipMessage(state: PlayerState): string { - return state.current?.title - ? translateWithFallback('chat.skippedNowPlaying', { title: state.current.title }, `Track skipped. Now playing: ${state.current.title}`) - : translateWithFallback('chat.skipped', undefined, 'Track skipped') +export function buildSkipMessage(state: PlayerState, locale: string): string { + return state.current?.title ? t(locale, 'chat.skippedNowPlaying', { title: state.current.title }) : t(locale, 'chat.skipped') } // "Queue [N]: 1. Title | 2. Title ... [+more]" / "The queue is empty" - shared by the HTTP chat endpoint and the Twitch chat module -export function buildQueueMessage(): string { - const state = getState() - +export function buildQueueMessage(state: PlayerState, locale: string): string { if (state.queue.length === 0) { - return translateWithFallback('chat.queueEmpty', undefined, 'The queue is empty') + return t(locale, 'chat.queueEmpty') } const maxShown = 4 @@ -46,41 +34,38 @@ export function buildQueueMessage(): string { .slice(0, maxShown) .map((item, i) => `${i + 1}. ${truncate(item.title)}`) .join(' | ') - const base = translateWithFallback('chat.queueList', { count: state.queue.length, list }, `Queue [${state.queue.length}]: ${list}`) - const more = - state.queue.length > maxShown - ? translateWithFallback('chat.queueMore', { count: state.queue.length - maxShown }, ` [+${state.queue.length - maxShown}]`) - : '' + const base = t(locale, 'chat.queueList', { count: state.queue.length, list }) + const more = state.queue.length > maxShown ? t(locale, 'chat.queueMore', { count: state.queue.length - maxShown }) : '' return base + more } // "@user, added to the queue [#N]" / "@user, <title> added, playing now" - confirmation of an accepted Channel Points request -export function buildRedemptionAcceptedMessage(userName: string, added: { song: { title: string }; started: boolean; position: number }): string { +export function buildRedemptionAcceptedMessage( + userName: string, + added: { song: { title: string }; started: boolean; position: number }, + locale: string +): string { const params = { user: userName, title: truncate(added.song.title), position: added.position } - return added.started - ? translateWithFallback('chat.redemption.acceptedNowPlaying', params, `@${userName}, ${params.title} added, playing now`) - : translateWithFallback('chat.redemption.accepted', params, `@${userName}, ${params.title} added to the queue [#${added.position}]`) + return added.started ? t(locale, 'chat.redemption.acceptedNowPlaying', params) : t(locale, 'chat.redemption.accepted', params) } // "@user, <reason>, points refunded" - one message per FailureReason, shared by the Channel Points // redemption flow (request rejected, or a later playback failure) wherever it needs to notify chat -export function buildRedemptionRejectionMessage(userName: string, reason: FailureReason): string { +export function buildRedemptionRejectionMessage(userName: string, reason: FailureReason, locale: string): string { // only a playback failure names the track (the one that could not be played); a request that was never queued has none const title = typeof reason.params?.title === 'string' ? truncate(reason.params.title) : undefined const params = { user: userName, ...reason.params, ...(title && { title }) } - return ( - t(getSettings().locale, `chat.redemption.${reason.code}`, params) ?? - translateWithFallback('chat.redemption.generic', params, `@${userName}, your request could not be completed, points refunded`) - ) + const specific = t(locale, `chat.redemption.${reason.code}`, params) + // If the specific key doesn't exist (returns the key itself), use generic + if (specific.startsWith('chat.redemption.')) { + return t(locale, 'chat.redemption.generic', params) + } + return specific } // Sent instead of the "points refunded" message when Twitch did not accept the cancellation: it must not promise a refund -export function buildRedemptionRefundFailedMessage(userName: string): string { - return translateWithFallback( - 'chat.redemption.refundFailed', - { user: userName }, - `@${userName}, your request could not be completed and the points could not be refunded automatically, the streamer will sort it out` - ) +export function buildRedemptionRefundFailedMessage(userName: string, locale: string): string { + return t(locale, 'chat.redemption.refundFailed', { user: userName }) } diff --git a/src/http.ts b/src/http.ts index 0dc2319..06720b9 100644 --- a/src/http.ts +++ b/src/http.ts @@ -14,7 +14,7 @@ export function ok<T extends object>(res: Response, data: T, status = 200): void export function fail(res: Response, error: string, code: ApiErrorCode, status: number, params?: Record<string, string | number>): void { const locale = getSettings().locale const localized = translateErrorCode(locale, code, params) - const body: ApiError = { success: false, error: localized ?? error, code, params } + const body: ApiError = { success: false, error: localized, code, params } res.status(status).json(body) } @@ -30,7 +30,7 @@ export function sendConfigUpdate<T extends object>(res: Response, config: T, rej const localized = translateErrorCode(getSettings().locale, 'INVALID_CONFIG', params) const body: ApiError & { data: ConfigUpdateResponse<T> } = { success: false, - error: localized ?? 'invalid config fields', + error: localized, code: 'INVALID_CONFIG', params, data diff --git a/src/i18n.ts b/src/i18n.ts index 9adf75e..d447311 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,7 +1,6 @@ import fs from 'node:fs' import path from 'node:path' import { getAppRoot } from './runtime.js' -import { getSettings } from './settings.js' import { createLogger, describeError } from './logger.js' // resolved when used, not when the module is imported @@ -34,7 +33,7 @@ function loadLocale(locale: string): Dict { } } -export function t(locale: string, key: string, params: Record<string, string | number> = {}): string | null { +export function t(locale: string, key: string, params: Record<string, string | number> = {}): string { let value: unknown = loadLocale(locale) for (const part of key.split('.')) { @@ -48,21 +47,17 @@ export function t(locale: string, key: string, params: Record<string, string | n if (typeof value !== 'string') { if (locale !== DEFAULT_LOCALE) return t(DEFAULT_LOCALE, key, params) - return null + return key // Fallback to key if not found in default locale } return Object.entries(params).reduce((acc, [param, replacement]) => acc.replaceAll(`{{${param}}}`, () => String(replacement)), value) } -export function translateWithFallback(key: string, params: Record<string, string | number> | undefined, fallback: string): string { - return t(getSettings().locale, key, params) ?? fallback -} - function codeToI18nKey(code: string): string { const camel = code.toLowerCase().replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) return `api.errors.${camel}` } -export function translateErrorCode(locale: string, code: string, params?: Record<string, string | number>): string | null { +export function translateErrorCode(locale: string, code: string, params?: Record<string, string | number>): string { return t(locale, codeToI18nKey(code), params) } diff --git a/src/integrations/twitch/chat.ts b/src/integrations/twitch/chat.ts index 5243bf3..cd6a1aa 100644 --- a/src/integrations/twitch/chat.ts +++ b/src/integrations/twitch/chat.ts @@ -85,8 +85,7 @@ export class TwitchChat extends ReconnectingSocket { async sendMessage(text: string): Promise<void> { if (!this.isConnected() || !this.socket) { - log.warn(`Cannot send message, not connected: ${text}`) - return + throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch chat is not connected') } // a line break would end the IRC command and let the rest be read as a new one; Twitch drops anything over 500 characters diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index ff7c6a9..175755b 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -20,7 +20,6 @@ import { buildRedemptionRejectionMessage, buildRedemptionRefundFailedMessage } from '../../chat-replies.js' -import { translateWithFallback } from '../../i18n.js' import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' import { getState, skipCurrent } from '../../player.js' import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish.js' @@ -29,6 +28,8 @@ import type { FailureReason, QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { emit, notify } from '../../state-events.js' import { normalizeTwitchError } from './error.js' +import { getSettings } from '../../settings.js' +import { t } from '../../i18n.js' const log = createLogger('TWITCH') const chatLog = createLogger('TWITCH CHAT') @@ -162,7 +163,8 @@ async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemptio } log.log(`Redemption ${event.id} added to queue`) - await replyInChat(buildRedemptionAcceptedMessage(event.user_name, result.added)) + const locale = getSettings().locale + await replyInChat(buildRedemptionAcceptedMessage(event.user_name, result.added, locale)) } let eventSubStart: Promise<void> | null = null @@ -232,10 +234,23 @@ function matchesCommand(text: string, command: string): boolean { } const CHAT_START_WAIT_MS = 5000 +const CHAT_RECONNECT_WAIT_MS = 60000 async function replyInChat(message: string): Promise<void> { - if (!chat?.isConnected() && chatStart) await Promise.race([chatStart, wait(CHAT_START_WAIT_MS)]) if (!chat) return + + // Wait for initial connection (5s) or reconnection (up to 60s) + if (!chat.isConnected()) { + const waitPromise = chatStart ? Promise.race([chatStart, wait(CHAT_START_WAIT_MS)]) : wait(CHAT_RECONNECT_WAIT_MS) + await waitPromise + } + + // Try to send, if still not connected after wait, just log and return + if (!chat.isConnected()) { + chatLog.warn(`Chat not connected, skipping reply: ${message}`) + return + } + await chat.sendMessage(message).catch((error) => { chatLog.error(`Failed to send chat reply: ${describeError(error)}`) }) @@ -255,27 +270,33 @@ async function runCommand(message: TwitchChatMessage, cooldown: keyof typeof las } const COMMANDS: Record<(typeof CHAT_COMMAND_KEYS)[number], { cooldown: keyof typeof lastRun; run: () => string }> = { - now: { cooldown: 'plainCooldownSeconds', run: buildNowPlayingMessage }, - queue: { cooldown: 'plainCooldownSeconds', run: buildQueueMessage }, + now: { + cooldown: 'plainCooldownSeconds', + run: () => buildNowPlayingMessage(getState(), getSettings().locale) + }, + queue: { + cooldown: 'plainCooldownSeconds', + run: () => buildQueueMessage(getState(), getSettings().locale) + }, skip: { cooldown: 'controlCooldownSeconds', run: () => { skipCurrent() - return buildSkipMessage(getState()) + return buildSkipMessage(getState(), getSettings().locale) } }, pause: { cooldown: 'controlCooldownSeconds', run: () => { setPaused(true) - return translateWithFallback('chat.paused', undefined, 'Player paused') + return t(getSettings().locale, 'chat.paused') } }, resume: { cooldown: 'controlCooldownSeconds', run: () => { setPaused(false) - return translateWithFallback('chat.resumed', undefined, 'Playback resumed') + return t(getSettings().locale, 'chat.resumed') } } } @@ -495,7 +516,8 @@ async function cancelRedemption(redemption: TrackedRedemption, twitchClient: Twi // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise if (result === 'done') { log.log(`Channel Points redemption canceled (points refunded): ${redemption.id}`) - await replyInChat(buildRedemptionRejectionMessage(redemption.userName, reason)) + const locale = getSettings().locale + await replyInChat(buildRedemptionRejectionMessage(redemption.userName, reason, locale)) return } @@ -503,7 +525,8 @@ async function cancelRedemption(redemption: TrackedRedemption, twitchClient: Twi // message here ("refunded" or "could not be refunded") could be wrong. Nothing is said in chat if (result === 'no-longer-open') return - await replyInChat(buildRedemptionRefundFailedMessage(redemption.userName)) + const locale = getSettings().locale + await replyInChat(buildRedemptionRefundFailedMessage(redemption.userName, locale)) } export type TwitchHealth = { diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 3335ceb..b345a50 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -1,13 +1,19 @@ import express from 'express' import { ok } from '../http.js' import { buildNowPlayingMessage, buildQueueMessage } from '../chat-replies.js' +import { getState } from '../player.js' +import { getSettings } from '../settings.js' export const router = express.Router() router.get('/now-playing', (_req, res) => { - ok(res, { message: buildNowPlayingMessage() }) + const state = getState() + const locale = getSettings().locale + ok(res, { message: buildNowPlayingMessage(state, locale) }) }) router.get('/queue', (_req, res) => { - ok(res, { message: buildQueueMessage() }) + const state = getState() + const locale = getSettings().locale + ok(res, { message: buildQueueMessage(state, locale) }) }) diff --git a/src/routes/player.ts b/src/routes/player.ts index 41b6b5b..d532c54 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -2,10 +2,11 @@ import express from 'express' import { PlayerActionResponse } from '../types.js' import { ok, fail } from '../http.js' import { isValidVideoId } from '../youtube/url.js' -import { translateWithFallback } from '../i18n.js' +import { t } from '../i18n.js' import { buildSkipMessage } from '../chat-replies.js' import { setPaused } from '../queue.js' import { getState, endCurrent, skipCurrent, reportPlaybackFailure } from '../player.js' +import { getSettings } from '../settings.js' export const router = express.Router() @@ -27,17 +28,20 @@ router.post('/ended', (req, res) => { router.post('/skip', (_req, res) => { skipCurrent() const state = getState() - ok<PlayerActionResponse>(res, { ...state, message: buildSkipMessage(state) }) + const locale = getSettings().locale + ok<PlayerActionResponse>(res, { ...state, message: buildSkipMessage(state, locale) }) }) router.post('/pause', (_req, res) => { setPaused(true) - ok<PlayerActionResponse>(res, { ...getState(), message: translateWithFallback('chat.paused', undefined, 'Player paused') }) + const locale = getSettings().locale + ok<PlayerActionResponse>(res, { ...getState(), message: t(locale, 'chat.paused') }) }) router.post('/resume', (_req, res) => { setPaused(false) - ok<PlayerActionResponse>(res, { ...getState(), message: translateWithFallback('chat.resumed', undefined, 'Playback resumed') }) + const locale = getSettings().locale + ok<PlayerActionResponse>(res, { ...getState(), message: t(locale, 'chat.resumed') }) }) router.post('/report-failure', (req, res) => { @@ -47,7 +51,8 @@ router.post('/report-failure', (req, res) => { const errorCode = typeof req.body?.errorCode === 'number' ? req.body.errorCode : undefined const applied = reportPlaybackFailure(errorCode, videoId) const state = getState() + const locale = getSettings().locale if (!applied) return ok(res, { ...state, ignored: true }) - ok<PlayerActionResponse>(res, { ...state, message: buildSkipMessage(state) }) + ok<PlayerActionResponse>(res, { ...state, message: buildSkipMessage(state, locale) }) }) diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 0fb6789..cc4eefb 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -2,10 +2,11 @@ import express from 'express' import { StateResponse, QueueRemoveResponse, QueueRequestResponse } from '../types.js' import { ok, fail, failFromReason } from '../http.js' import { isLocalAddress } from '../local-only.js' -import { translateWithFallback } from '../i18n.js' +import { t } from '../i18n.js' import { requestSong, removeByVideoId, clearQueue } from '../queue.js' import { isValidVideoId } from '../youtube/url.js' import { getState } from '../player.js' +import { getSettings } from '../settings.js' export const router = express.Router() @@ -33,12 +34,8 @@ router.post('/request', async (req, res) => { case 'added': { const { added } = result const message = added.started - ? translateWithFallback('toast.nowPlaying', { title: added.song.title }, `Now playing: ${added.song.title}`) - : translateWithFallback( - 'toast.addedToQueue', - { title: added.song.title, position: added.position }, - `Added to queue: ${added.song.title} [#${added.position}]` - ) + ? t(getSettings().locale, 'toast.nowPlaying', { title: added.song.title }) + : t(getSettings().locale, 'toast.addedToQueue', { title: added.song.title, position: added.position }) return ok<QueueRequestResponse>(res, { ...added, state: getState(), message }, 201) } } From 492b711768f23a2fc768d7d688dcc682ba518453 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:24:21 +0700 Subject: [PATCH 04/22] Harden API errors and Twitch authentication flows Error handling: - localize API errors by code - surface Twitch authorization failures in the control panel Twitch: - improve retry, reconnect, and token rollback handling - serialize immediate persistence saves to prevent race conditions YouTube: - preserve raw search results so cached data can be re-filtered - block all YouTube API requests while quota exhaustion is active --- public/js/control/notices.js | 8 +- public/locales/en.json | 2 + public/locales/ru.json | 2 + src/chat-replies.ts | 12 +- src/error-handler.ts | 8 +- src/host-check.ts | 2 +- src/http.ts | 15 +- src/i18n.ts | 42 ++- src/integrations/twitch/client.ts | 14 +- src/integrations/twitch/error.ts | 21 +- src/integrations/twitch/index.ts | 103 +++++-- src/integrations/twitch/oauth.ts | 14 +- src/local-only.ts | 2 +- src/persist.ts | 13 +- src/routes/blocklist.ts | 4 +- src/routes/fallback.ts | 2 +- src/routes/index.ts | 2 +- src/routes/player.ts | 4 +- src/routes/playlists.ts | 10 +- src/routes/queue.ts | 12 +- src/routes/search.ts | 2 +- src/routes/settings.ts | 4 +- src/sse.ts | 2 +- src/state-events.ts | 4 +- src/youtube/cache.ts | 49 +-- src/youtube/client.ts | 11 +- src/youtube/index.ts | 118 +++----- tests/backend/error-codes.test.ts | 9 +- tests/backend/i18n.test.ts | 16 +- .../integrations/twitch/chat-commands.test.ts | 68 ++++- .../integrations/twitch/device-auth.test.ts | 43 +++ .../integrations/twitch/redemptions.test.ts | 5 + tests/backend/persist.test.ts | 79 +++++ tests/backend/routes/queue.test.ts | 10 +- tests/backend/youtube/quota.no-key.test.ts | 128 -------- tests/backend/youtube/quota.test.ts | 285 ++++++++++-------- tests/backend/youtube/request.test.ts | 20 +- tests/frontend/control/notices.test.js | 12 + 38 files changed, 679 insertions(+), 478 deletions(-) delete mode 100644 tests/backend/youtube/quota.no-key.test.ts diff --git a/public/js/control/notices.js b/public/js/control/notices.js index 8a977f5..efb6646 100644 --- a/public/js/control/notices.js +++ b/public/js/control/notices.js @@ -1,10 +1,14 @@ import { t } from '../i18n.js' -import { toastInfo } from './toast.js' +import { translateErrorCode } from '../shared.js' +import { toastError, toastInfo } from './toast.js' // What the server tells the panel is only shown: that it is worth telling, and what it is about, was decided there. // A kind of notice that this panel does not know is left alone, so a newer server can tell more than an older panel shows const NOTICES = { - viewerRequest: ({ user, title }) => toastInfo(t('toast.viewerRequest', { user, title })) + viewerRequest: ({ user, title }) => toastInfo(t('toast.viewerRequest', { user, title })), + // the code is translated like any API error, so the text is the panel's language; the fallback covers a + // code this panel has no translation for + error: ({ code, params }) => toastError(translateErrorCode(t, code, params, t('api.errors.unexpectedError'))) } export function showNotice(notice) { diff --git a/public/locales/en.json b/public/locales/en.json index 46ce79c..5bd56b0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -236,6 +236,7 @@ "playlistLoadFailed": "Failed to load playlist, check the ID", "invalidQuery": "Query must be at least 2 characters", "invalidQueryRange": "Query must be between 2 and 200 characters", + "invalidRequest": "Invalid request", "invalidYoutubeUrl": "Invalid YouTube URL", "songNotFound": "Could not find a suitable track", "usernameRequired": "Username is required", @@ -271,6 +272,7 @@ "twitchAuthExpired": "Twitch authorization expired, try connecting again", "twitchNotConnected": "Twitch account is not connected", "twitchRefreshError": "Failed to refresh Twitch connection", + "twitchApiError": "Twitch API error: {{message}}", "twitchNetworkError": "No connection to Twitch", "twitchUnknownError": "Unknown Twitch error" } diff --git a/public/locales/ru.json b/public/locales/ru.json index cdec321..47ce638 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -236,6 +236,7 @@ "playlistLoadFailed": "Не удалось загрузить плейлист, проверьте ID", "invalidQuery": "Запрос должен содержать минимум 2 символа", "invalidQueryRange": "Запрос должен быть от 2 до 200 символов", + "invalidRequest": "Некорректный запрос", "invalidYoutubeUrl": "Некорректная ссылка на YouTube", "songNotFound": "Не удалось найти подходящий трек", "usernameRequired": "Имя пользователя обязательно", @@ -271,6 +272,7 @@ "twitchAuthExpired": "Время авторизации Twitch истекло, попробуйте подключиться снова", "twitchNotConnected": "Аккаунт Twitch не подключён", "twitchRefreshError": "Не удалось обновить подключение Twitch", + "twitchApiError": "Ошибка Twitch API: {{message}}", "twitchNetworkError": "Нет связи с Twitch", "twitchUnknownError": "Неизвестная ошибка Twitch" } diff --git a/src/chat-replies.ts b/src/chat-replies.ts index 8e4918e..f036d7a 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -1,4 +1,4 @@ -import { t } from './i18n.js' +import { t, hasKey } from './i18n.js' import type { PlayerState, FailureReason } from './types.js' const truncate = (s: string, max = 40) => { @@ -57,12 +57,10 @@ export function buildRedemptionRejectionMessage(userName: string, reason: Failur const title = typeof reason.params?.title === 'string' ? truncate(reason.params.title) : undefined const params = { user: userName, ...reason.params, ...(title && { title }) } - const specific = t(locale, `chat.redemption.${reason.code}`, params) - // If the specific key doesn't exist (returns the key itself), use generic - if (specific.startsWith('chat.redemption.')) { - return t(locale, 'chat.redemption.generic', params) - } - return specific + const key = `chat.redemption.${reason.code}` + // an explicit check: a template that resolves is allowed to start with the same words as the key + if (!hasKey(locale, key)) return t(locale, 'chat.redemption.generic', params) + return t(locale, key, params) } // Sent instead of the "points refunded" message when Twitch did not accept the cancellation: it must not promise a refund diff --git a/src/error-handler.ts b/src/error-handler.ts index c9c2324..a915266 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -14,12 +14,12 @@ export function errorHandler(err: HttpError, _req: Request, res: Response, next: } if (err instanceof AppError) return failFromError(res, err) - if (err.type === 'entity.parse.failed') return fail(res, 'request body is not valid JSON', 'INVALID_JSON', 400) - if (err.type === 'entity.too.large') return fail(res, 'request body is too large', 'PAYLOAD_TOO_LARGE', 413) + if (err.type === 'entity.parse.failed') return fail(res, 'INVALID_JSON', 400) + if (err.type === 'entity.too.large') return fail(res, 'PAYLOAD_TOO_LARGE', 413) const status = err.status ?? err.statusCode - if (typeof status === 'number' && status >= 400 && status < 500) return fail(res, 'bad request', 'INVALID_REQUEST', status) + if (typeof status === 'number' && status >= 400 && status < 500) return fail(res, 'INVALID_REQUEST', status) log.error(describeError(err, true)) - fail(res, 'internal server error', 'SERVER_ERROR', 500) + fail(res, 'SERVER_ERROR', 500) } diff --git a/src/host-check.ts b/src/host-check.ts index 87aca5d..b118639 100644 --- a/src/host-check.ts +++ b/src/host-check.ts @@ -36,5 +36,5 @@ export function hostCheck(req: Request, res: Response, next: NextFunction): void if (isAllowedHost(req.headers.host) && originAllowed) return next() - fail(res, 'this host is not allowed', 'FORBIDDEN_ORIGIN', 403) + fail(res, 'FORBIDDEN_ORIGIN', 403) } diff --git a/src/http.ts b/src/http.ts index 06720b9..0bb43da 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,7 +1,7 @@ import { Response } from 'express-serve-static-core' import { AppError, AppErrorCode, ApiOk, ApiError, ApiErrorCode, ConfigUpdateResponse } from './types.js' import { getSettings } from './settings.js' -import { translateErrorCode } from './i18n.js' +import { translateApiError } from './i18n.js' import { createLogger, describeError } from './logger.js' const log = createLogger('API') @@ -11,10 +11,9 @@ export function ok<T extends object>(res: Response, data: T, status = 200): void res.status(status).json(body) } -export function fail(res: Response, error: string, code: ApiErrorCode, status: number, params?: Record<string, string | number>): void { +export function fail(res: Response, code: ApiErrorCode, status: number, params?: Record<string, string | number>): void { const locale = getSettings().locale - const localized = translateErrorCode(locale, code, params) - const body: ApiError = { success: false, error: localized, code, params } + const body: ApiError = { success: false, error: translateApiError(locale, code, params), code, params } res.status(status).json(body) } @@ -27,7 +26,7 @@ export function sendConfigUpdate<T extends object>(res: Response, config: T, rej } const params = { fields: rejected.join(', ') } - const localized = translateErrorCode(getSettings().locale, 'INVALID_CONFIG', params) + const localized = translateApiError(getSettings().locale, 'INVALID_CONFIG', params) const body: ApiError & { data: ConfigUpdateResponse<T> } = { success: false, error: localized, @@ -69,7 +68,7 @@ export type ErrorInfo = { code: string; status: number; message: string; params? export function getErrorInfo(error: unknown): ErrorInfo { if (error instanceof AppError) { - return { code: error.code, status: STATUS_BY_CODE[error.code], message: error.message, params: error.params } + return { code: error.code, status: STATUS_BY_CODE[error.code], message: error.message, params: { ...error.params, message: error.message } } } log.error(`Unexpected error: ${describeError(error, true)}`) @@ -78,12 +77,12 @@ export function getErrorInfo(error: unknown): ErrorInfo { export function failFromError(res: Response, error: unknown): void { const info = getErrorInfo(error) - fail(res, info.message, info.code as ApiErrorCode, info.status, info.params) + fail(res, info.code as ApiErrorCode, info.status, info.params) } // same as failFromError, for callers that already have a structured { code, params } reason // instead of the original thrown error (e.g. RequestSongResult's 'error' outcome) export function failFromReason(res: Response, reason: { code: string; params?: Record<string, string | number> }): void { const status = STATUS_BY_CODE[reason.code as AppErrorCode] ?? 500 - fail(res, reason.code, reason.code as ApiErrorCode, status, reason.params) + fail(res, reason.code as ApiErrorCode, status, reason.params) } diff --git a/src/i18n.ts b/src/i18n.ts index d447311..d333efd 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -33,7 +33,8 @@ function loadLocale(locale: string): Dict { } } -export function t(locale: string, key: string, params: Record<string, string | number> = {}): string { +// The template a key resolves to, with the default locale as the fallback: undefined when neither has it +function template(locale: string, key: string): string | undefined { let value: unknown = loadLocale(locale) for (const part of key.split('.')) { @@ -45,19 +46,46 @@ export function t(locale: string, key: string, params: Record<string, string | n } } - if (typeof value !== 'string') { - if (locale !== DEFAULT_LOCALE) return t(DEFAULT_LOCALE, key, params) - return key // Fallback to key if not found in default locale - } + if (typeof value === 'string') return value + return locale === DEFAULT_LOCALE ? undefined : template(DEFAULT_LOCALE, key) +} + +export function t(locale: string, key: string, params: Record<string, string | number> = {}): string { + const value = template(locale, key) + if (value === undefined) return key // the key itself is the last resort; hasKey() tells it apart from a real text return Object.entries(params).reduce((acc, [param, replacement]) => acc.replaceAll(`{{${param}}}`, () => String(replacement)), value) } +// Whether a translation exists (in the locale, or in the default one): the way to tell a missing key from +// a text that resolved, instead of comparing the result of t() with the key +export function hasKey(locale: string, key: string): boolean { + return template(locale, key) !== undefined +} + function codeToI18nKey(code: string): string { const camel = code.toLowerCase().replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) return `api.errors.${camel}` } -export function translateErrorCode(locale: string, code: string, params?: Record<string, string | number>): string { - return t(locale, codeToI18nKey(code), params) +// Null for a code with no message: the caller decides what to show instead (the key itself must not reach the user) +export function translateErrorCode(locale: string, code: string, params?: Record<string, string | number>): string | null { + const key = codeToI18nKey(code) + if (!hasKey(locale, key)) return null + return t(locale, key, params) +} + +const reportedMissingCodes = new Set<string>() + +// The text shown to the user for an API error. A code with no translation is a gap in the locales: it is logged +// (once per code, so a loop cannot fill the log) and answered with the generic failure message instead of the key +export function translateApiError(locale: string, code: string, params?: Record<string, string | number>): string { + const localized = translateErrorCode(locale, code, params) + if (localized !== null) return localized + + if (!reportedMissingCodes.has(code)) { + reportedMissingCodes.add(code) + log.warn(`No translation for error code "${code}", showing the generic failure message`) + } + return t(locale, 'api.errors.unexpectedError') } diff --git a/src/integrations/twitch/client.ts b/src/integrations/twitch/client.ts index 8e9d539..79665e4 100644 --- a/src/integrations/twitch/client.ts +++ b/src/integrations/twitch/client.ts @@ -12,7 +12,7 @@ import type { import { TwitchOAuth } from './oauth.js' import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' -import { normalizeTwitchError } from './error.js' +import { markTransient, normalizeTwitchError } from './error.js' const log = createLogger('TWITCH CLIENT') @@ -32,7 +32,9 @@ export class TwitchClient { private async makeAuthenticatedRequest<T>(url: string, options: RequestInit = {}): Promise<T> { const accessToken = await this.oauth.getValidAccessToken() if (!accessToken) { - throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected', this.refreshMayWorkLater() ? { transient: 1 } : undefined) + const notConnected = new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected') + // a login exists and Twitch has not refused it: a failed refresh was a passing problem, the request may work later + throw this.refreshMayWorkLater() ? markTransient(notConnected) : notConnected } const response = await this.request(url, options, accessToken) @@ -47,11 +49,9 @@ export class TwitchClient { } catch (error) { const normalized = normalizeTwitchError(error) log.error(`Token refresh failed: ${describeError(normalized)}`) - // Preserve transient flag if already present, otherwise determine based on refreshMayWorkLater - if (!normalized.params?.transient && this.refreshMayWorkLater()) { - normalized.params = { ...normalized.params, transient: 1 } - } - throw normalized + // a login exists and Twitch has not refused it: the refresh failure was passing, a retry is worth it + // (an error that already carries the flag keeps it, whatever the login looks like) + throw this.refreshMayWorkLater() ? markTransient(normalized) : normalized } // an error of the retried request itself is its own error, not a failed login diff --git a/src/integrations/twitch/error.ts b/src/integrations/twitch/error.ts index c6089ba..ff902a2 100644 --- a/src/integrations/twitch/error.ts +++ b/src/integrations/twitch/error.ts @@ -1,11 +1,28 @@ import { AppError } from '../../types.js' +// `transient: 1` on an error's params says the same attempt may work later (a lost connection, a login that +// could not be refreshed just now), so a retry is worth it. The literal lives here alone, and the error is +// rebuilt rather than mutated +export function markTransient(error: AppError): AppError { + if (error.params?.transient === 1) return error + return new AppError(error.code, error.message, { ...error.params, transient: 1 }) +} + +// fetch fails as a TypeError ("fetch failed", "terminated"), and a timeout as a DOMException named +// TimeoutError or AbortError: those are "no connection to Twitch". Any other Error is something else (a +// refusal, a bug of our own) and must not be reported as a missing connection +export function isNetworkError(error: unknown): boolean { + if (error instanceof TypeError) return true + return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError') +} + export function normalizeTwitchError(error: unknown): AppError { if (error instanceof AppError) return error if (error instanceof Error) { - // Network errors (no connection, timeout, DNS failure) - return new AppError('TWITCH_NETWORK_ERROR', error.message, { transient: 1 }) + return isNetworkError(error) + ? markTransient(new AppError('TWITCH_NETWORK_ERROR', error.message)) + : new AppError('TWITCH_UNKNOWN_ERROR', error.message) } return new AppError('TWITCH_UNKNOWN_ERROR', String(error)) diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 175755b..106a6ef 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -27,7 +27,7 @@ import { AppError } from '../../types.js' import type { FailureReason, QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { emit, notify } from '../../state-events.js' -import { normalizeTwitchError } from './error.js' +import { normalizeTwitchError, isNetworkError } from './error.js' import { getSettings } from '../../settings.js' import { t } from '../../i18n.js' @@ -36,6 +36,7 @@ const chatLog = createLogger('TWITCH CHAT') const REDEMPTION_RETRY_ATTEMPTS = 3 const REDEMPTION_RETRY_DELAY_MS = 1000 +const USER_INFO_RETRY_ATTEMPTS = 3 let retryDelayMs = REDEMPTION_RETRY_DELAY_MS let oauth: TwitchOAuth | null = null @@ -235,20 +236,32 @@ function matchesCommand(text: string, command: string): boolean { const CHAT_START_WAIT_MS = 5000 const CHAT_RECONNECT_WAIT_MS = 60000 +const CHAT_CONNECT_POLL_MS = 250 +let chatStartWaitMs = CHAT_START_WAIT_MS +let chatReconnectWaitMs = CHAT_RECONNECT_WAIT_MS + +async function waitForChat(limitMs: number): Promise<boolean> { + const deadline = Date.now() + limitMs + + while (!chat?.isConnected()) { + if (!chat) return false + const remaining = deadline - Date.now() + if (remaining <= 0) return false + await wait(Math.min(CHAT_CONNECT_POLL_MS, remaining)) + } + + return true +} async function replyInChat(message: string): Promise<void> { if (!chat) return - // Wait for initial connection (5s) or reconnection (up to 60s) - if (!chat.isConnected()) { - const waitPromise = chatStart ? Promise.race([chatStart, wait(CHAT_START_WAIT_MS)]) : wait(CHAT_RECONNECT_WAIT_MS) - await waitPromise - } - - // Try to send, if still not connected after wait, just log and return if (!chat.isConnected()) { - chatLog.warn(`Chat not connected, skipping reply: ${message}`) - return + const connected = await waitForChat(chatStart ? chatStartWaitMs : chatReconnectWaitMs) + if (!connected) { + chatLog.warn(`Chat not connected, skipping reply: ${message}`) + return + } } await chat.sendMessage(message).catch((error) => { @@ -383,32 +396,45 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon .pollForToken(device.device_code, device.interval, device.expires_in, () => generation === deviceAuthorizationGeneration) .then(async () => { if (generation !== deviceAuthorizationGeneration) { - throw new Error('Device authorization was cancelled') + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') } - if (!client) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client is not initialized') + if (!oauth || !client) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client is not initialized') let fetchedUserInfo: TwitchUserInfo | null = null - let lastError: Error | null = null + let lastError: unknown = null + + for (let attempt = 1; attempt <= USER_INFO_RETRY_ATTEMPTS; attempt++) { + if (generation !== deviceAuthorizationGeneration) { + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } - for (let attempt = 1; attempt <= 3; attempt++) { try { fetchedUserInfo = await client.getUserInfo() break } catch (error) { - lastError = error as Error - if (attempt < 3) { - log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) - await new Promise((resolve) => setTimeout(resolve, 1000)) - } + lastError = error + // a refusal (4xx) or a refused token gives the same answer every time: repeating it only delays the rollback + if (attempt === USER_INFO_RETRY_ATTEMPTS || !isRetryable(error)) break + log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) + await wait(retryDelayMs * 2 ** (attempt - 1)) } } if (!fetchedUserInfo) { - log.error(`getUserInfo failed after 3 attempts: ${describeError(lastError)}`) - // Rollback tokens on failure - await oauth?.revokeTokens() - oauth?.clearTokenData() - throw new AppError('TWITCH_AUTH_ERROR', 'Failed to fetch user info after successful login. Please try connecting again.') + log.error(`getUserInfo failed: ${describeError(lastError)}`) + if (generation === deviceAuthorizationGeneration) { + await oauth.revokeTokens() + oauth.clearTokenData() + clearTwitchOAuthState() + throw lastError instanceof AppError + ? lastError + : new AppError('TWITCH_AUTH_ERROR', 'Failed to fetch user info after successful login. Please try connecting again.') + } + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } + + if (generation !== deviceAuthorizationGeneration) { + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') } updateTwitchOAuthState({ @@ -424,8 +450,18 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon }) // nobody awaits this promise: a refusal, an expired code or a cancel is logged here instead of becoming an unhandled rejection .catch((error) => { - if (generation === deviceAuthorizationGeneration) log.error(`Twitch authorization failed: ${describeError(error)}`) - else log.log('Twitch authorization was cancelled') + if (generation !== deviceAuthorizationGeneration) { + log.log('Twitch authorization was cancelled') + return + } + + log.error(`Twitch authorization failed: ${describeError(error)}`) + notify({ + type: 'error', + level: 'error', + code: error instanceof AppError ? error.code : 'TWITCH_AUTH_ERROR', + params: error instanceof AppError ? { ...error.params, message: error.message } : undefined + }) }) .finally(() => { if (generation !== deviceAuthorizationGeneration) return @@ -441,11 +477,12 @@ function wait(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)) } -// A repeat helps only when the failure can pass by itself: a lost connection or a timeout (they arrive as plain errors), -// a 429, a 5xx, or a login that could not be refreshed right now (flagged `transient`). A refusal (4xx), a missing -// login or a token Twitch no longer accepts gives the same answer every time +// A repeat helps only when the failure can pass by itself: a lost connection or a timeout (they arrive as +// plain errors where nothing normalized them on the way), a 429, a 5xx, or a login that could not be +// refreshed right now (flagged `transient`). A refusal (4xx), a missing login or a token Twitch no longer +// accepts gives the same answer every time, and any other error is a bug: repeating it would only repeat the bug export function isRetryable(error: unknown): boolean { - if (!(error instanceof AppError)) return true + if (!(error instanceof AppError)) return isNetworkError(error) if (error.params?.transient === 1) return true const status = error.params?.status @@ -613,6 +650,10 @@ export const _test = { setChat: (mock: TwitchChat | null) => { chat = mock }, + setChatWaitLimits: (ms: { start: number; reconnect: number }) => { + chatStartWaitMs = ms.start + chatReconnectWaitMs = ms.reconnect + }, cancelRedemption, fulfillRedemption, handleChannelPointsRedemption, @@ -635,4 +676,6 @@ export async function _resetIntegration(): Promise<void> { deviceAuthorizationGeneration = 0 handledRedemptions.clear() lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 + chatStartWaitMs = CHAT_START_WAIT_MS + chatReconnectWaitMs = CHAT_RECONNECT_WAIT_MS } diff --git a/src/integrations/twitch/oauth.ts b/src/integrations/twitch/oauth.ts index bfd4681..251686d 100644 --- a/src/integrations/twitch/oauth.ts +++ b/src/integrations/twitch/oauth.ts @@ -79,7 +79,8 @@ export class TwitchOAuth { log.log('Device code requested') return data } catch (error) { - log.error(`Device code request failed: ${describeError(error)}`) + // the error is reported where it ends (the /connect answer carries it to the panel): + // logging it here as well makes every failure appear twice throw normalizeTwitchError(error) } } @@ -257,9 +258,14 @@ export class TwitchOAuth { const response = await postForm(TOKEN_URL, params) if (!response.ok) { - // Twitch refused the refresh token itself (expired after 30 days of a public client, revoked, password changed): - // retrying is pointless, the account has to be connected again. A network error or a 5xx is not that - if (response.status === 400 || response.status === 401) this.setAuthFailed(true) + // Twitch refused the refresh token itself (expired after 30 days of a public client, revoked, password + // changed): retrying is pointless, the account has to be connected again. A network error or a 5xx is not + // that, and is reported as the API error it is + if (response.status === 400 || response.status === 401) { + this.setAuthFailed(true) + const body = (await response.json().catch(() => ({}))) as TwitchErrorResponse + throw new AppError('TWITCH_REFRESH_ERROR', body.message || 'Token refresh failed') + } throw await oauthError(response, 'Token refresh failed') } diff --git a/src/local-only.ts b/src/local-only.ts index 43a2d0d..725ee09 100644 --- a/src/local-only.ts +++ b/src/local-only.ts @@ -36,5 +36,5 @@ export function localOnly(req: Request, res: Response, next: NextFunction): void return } - fail(res, 'this action is only available from the computer the app is running on', 'LOCAL_ONLY', 403) + fail(res, 'LOCAL_ONLY', 403) } diff --git a/src/persist.ts b/src/persist.ts index 9fa85ab..00c3d12 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -75,12 +75,13 @@ export function createFileStore<T>(location: string | (() => string)) { await rename(tmpPath, filePath) } - async function saveNow(getData: () => T, onError: (error: unknown) => void): Promise<void> { - try { - await persistNow(getData) - } catch (error) { - onError(error) - } + function saveNow(getData: () => T, onError: (error: unknown) => void): Promise<void> { + const settled = saveChain.then(() => persistNow(getData)).catch(onError) + saveChain = settled.then( + () => {}, + () => {} + ) + return settled } function enqueue(job: SaveJob): void { diff --git a/src/routes/blocklist.ts b/src/routes/blocklist.ts index 271b86c..1cea0ec 100644 --- a/src/routes/blocklist.ts +++ b/src/routes/blocklist.ts @@ -14,7 +14,7 @@ router.post('/', (req, res) => { const { videoId, title } = req.body ?? {} if (!isValidVideoId(videoId)) { - return fail(res, 'a valid videoId is required', 'INVALID_VIDEO_ID', 400) + return fail(res, 'INVALID_VIDEO_ID', 400) } const entry = blockTrack(videoId, typeof title === 'string' ? title.slice(0, 200) : videoId) @@ -24,6 +24,6 @@ router.post('/', (req, res) => { router.delete('/:videoId', (req, res) => { const removed = unblockTrack(req.params.videoId) - if (!removed) return fail(res, 'not found', 'NOT_FOUND', 404) + if (!removed) return fail(res, 'NOT_FOUND', 404) ok(res, { removed: true }) }) diff --git a/src/routes/fallback.ts b/src/routes/fallback.ts index 946a2cf..fa8d172 100644 --- a/src/routes/fallback.ts +++ b/src/routes/fallback.ts @@ -14,7 +14,7 @@ import { getState } from '../player.js' function fallbackTrackOrNotFound(res: express.Response, item: QueueItem | null): item is QueueItem { if (!item) { - fail(res, 'track not found in fallback', 'NOT_FOUND', 404) + fail(res, 'NOT_FOUND', 404) return false } return true diff --git a/src/routes/index.ts b/src/routes/index.ts index 146b33d..e109f5d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -35,5 +35,5 @@ apiRouter.use('/integrations/twitch', twitchRouter) apiRouter.use((req, res) => { log.warn(`Unknown endpoint: ${req.method} ${req.originalUrl}`) - fail(res, 'API endpoint not found', 'NOT_FOUND', 404) + fail(res, 'NOT_FOUND', 404) }) diff --git a/src/routes/player.ts b/src/routes/player.ts index d532c54..4aa3491 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -19,7 +19,7 @@ function readVideoId(body: unknown): { videoId?: string; valid: boolean } { router.post('/ended', (req, res) => { const { videoId, valid } = readVideoId(req.body) - if (!valid) return fail(res, 'a valid videoId is required', 'INVALID_VIDEO_ID', 400) + if (!valid) return fail(res, 'INVALID_VIDEO_ID', 400) const applied = endCurrent(videoId) ok(res, { ...getState(), ...(applied ? {} : { ignored: true }) }) @@ -46,7 +46,7 @@ router.post('/resume', (_req, res) => { router.post('/report-failure', (req, res) => { const { videoId, valid } = readVideoId(req.body) - if (!valid) return fail(res, 'a valid videoId is required', 'INVALID_VIDEO_ID', 400) + if (!valid) return fail(res, 'INVALID_VIDEO_ID', 400) const errorCode = typeof req.body?.errorCode === 'number' ? req.body.errorCode : undefined const applied = reportPlaybackFailure(errorCode, videoId) diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index da5dfbf..c576c0c 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -28,13 +28,13 @@ router.post('/', async (req, res) => { const parsedId = parsePlaylistId(raw) if (!parsedId) { - return fail(res, 'invalid playlist ID or URL', 'INVALID_PLAYLIST_ID', 400) + return fail(res, 'INVALID_PLAYLIST_ID', 400) } try { const meta = await fetchPlaylistMeta(parsedId) if (!meta) { - return fail(res, 'playlist not found', 'PLAYLIST_NOT_FOUND', 404) + return fail(res, 'PLAYLIST_NOT_FOUND', 404) } const saved = upsertPlaylist(meta) ok(res, { playlist: saved }, 201) @@ -46,7 +46,7 @@ router.post('/', async (req, res) => { router.delete('/:id', (req, res) => { const removed = removePlaylist(req.params.id) if (!removed) { - return fail(res, 'playlist not found', 'PLAYLIST_NOT_FOUND', 404) + return fail(res, 'PLAYLIST_NOT_FOUND', 404) } const wasActive = getConfig().fallbackPlaylist.playlistId === req.params.id @@ -63,11 +63,11 @@ router.post('/:id/activate', async (req, res) => { const { id } = req.params if (!isValidPlaylistId(id)) { - return fail(res, 'invalid playlist ID or URL', 'INVALID_PLAYLIST_ID', 400) + return fail(res, 'INVALID_PLAYLIST_ID', 400) } if (!getPlaylists().some((playlist) => playlist.id === id)) { - return fail(res, 'playlist not found', 'PLAYLIST_NOT_FOUND', 404) + return fail(res, 'PLAYLIST_NOT_FOUND', 404) } const previousId = getConfig().fallbackPlaylist.playlistId diff --git a/src/routes/queue.ts b/src/routes/queue.ts index cc4eefb..bfac870 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -15,20 +15,20 @@ router.post('/request', async (req, res) => { const bypassFilters = admin === true && isLocalAddress(req.socket.remoteAddress) if (typeof query !== 'string' || query.trim().length < 2 || query.trim().length > 200) { - return fail(res, 'query must be between 2 and 200 characters', 'INVALID_QUERY', 400) + return fail(res, 'INVALID_QUERY', 400) } if (typeof requestedBy !== 'string' || requestedBy.trim().length === 0) { - return fail(res, 'username is required', 'USERNAME_REQUIRED', 400) + return fail(res, 'USERNAME_REQUIRED', 400) } const result = await requestSong(query.trim(), requestedBy.trim().slice(0, 50), { bypassFilters }) switch (result.outcome) { case 'invalid-url': - return fail(res, 'invalid YouTube URL', 'INVALID_YOUTUBE_URL', 400) + return fail(res, 'INVALID_YOUTUBE_URL', 400) case 'not-found': - return fail(res, 'could not find a suitable track', 'SONG_NOT_FOUND', 404) + return fail(res, 'SONG_NOT_FOUND', 404) case 'error': return failFromReason(res, result.reason) case 'added': { @@ -45,13 +45,13 @@ router.delete('/video/:videoId', (req, res) => { const { videoId } = req.params if (!isValidVideoId(videoId)) { - return fail(res, 'a valid videoId is required', 'INVALID_VIDEO_ID', 400) + return fail(res, 'INVALID_VIDEO_ID', 400) } const removed = removeByVideoId(videoId) if (!removed) { - return fail(res, 'queue item not found', 'QUEUE_ITEM_NOT_FOUND', 404) + return fail(res, 'QUEUE_ITEM_NOT_FOUND', 404) } ok<QueueRemoveResponse>(res, { removed, state: getState() }) diff --git a/src/routes/search.ts b/src/routes/search.ts index 9bc872f..171779a 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -12,7 +12,7 @@ router.get('/', async (req, res) => { const bypassFilters = req.query.admin === '1' && isLocalAddress(req.socket.remoteAddress) if (query.length < 2) { - return fail(res, 'query must be at least 2 characters', 'INVALID_QUERY', 400) + return fail(res, 'INVALID_QUERY', 400) } const songs = await searchSongs(query, bypassFilters) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 94857f8..ba2a8e4 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -29,7 +29,7 @@ router.put('/config', async (req, res) => { if (body.fallbackPlaylist !== undefined) { if (typeof body.fallbackPlaylist !== 'object' || body.fallbackPlaylist === null || Array.isArray(body.fallbackPlaylist)) { - fail(res, 'invalid fallbackPlaylist: must be an object', 'INVALID_CONFIG', 400, { fields: 'fallbackPlaylist' }) + fail(res, 'INVALID_CONFIG', 400, { fields: 'fallbackPlaylist' }) return } @@ -39,7 +39,7 @@ router.put('/config', async (req, res) => { if (rawPlaylistId) { const parsedId = parsePlaylistId(rawPlaylistId) if (!parsedId) { - fail(res, 'invalid playlist ID or URL', 'INVALID_PLAYLIST_ID', 400) + fail(res, 'INVALID_PLAYLIST_ID', 400) return } body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: parsedId } diff --git a/src/sse.ts b/src/sse.ts index f24c95f..a5dbc43 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -95,7 +95,7 @@ export function createEventStreams({ maxClients = 20, pingEveryMs = 25_000, coal function handle(req: Request, res: Response): void { if (clients.size >= maxClients) { - fail(res, 'too many open event streams', 'SERVER_ERROR', 503) + fail(res, 'SERVER_ERROR', 503) return } diff --git a/src/state-events.ts b/src/state-events.ts index 2f88b58..7cef898 100644 --- a/src/state-events.ts +++ b/src/state-events.ts @@ -20,7 +20,9 @@ export function on(topic: Topic, listener: Listener): () => void { // A notice is something that happened and is worth telling to whoever watches the panel, with what it is about. // Unlike a change it is not read again from anywhere: it exists only as long as it is being told -export type Notice = { type: 'viewerRequest'; user: string; title: string } +export type Notice = + | { type: 'viewerRequest'; user: string; title: string } + | { type: 'error'; level: 'error'; code: string; params?: Record<string, string | number> } const noticeListeners = new Set<(notice: Notice) => void>() diff --git a/src/youtube/cache.ts b/src/youtube/cache.ts index a064866..8ccbe65 100644 --- a/src/youtube/cache.ts +++ b/src/youtube/cache.ts @@ -1,4 +1,5 @@ -import { Song, FilterFailureReason } from '../types.js' +import { createLogger } from '../logger.js' +import type { VideoItem } from './types.js' export const CACHE_LIMITS = { VIDEO_CACHE_TTL: 10 * 60 * 1000, @@ -9,23 +10,35 @@ const MAX_ENTRIES = 200 type Entry<T> = { value: T; expiresAt: number } -const searches = new Map<string, Entry<Song[]>>() -const videos = new Map<string, Entry<VideoCacheResult>>() +// Both caches hold the raw answers from YouTube, not the songs the filters picked out of them: what a change +// of the filters invalidates is decided when the data is read, so it does not throw away paid-for requests +const searches = new Map<string, Entry<VideoItem[]>>() +const videos = new Map<string, Entry<VideoItem | null>>() + +const log = createLogger('YOUTUBE QUOTA') + +// YouTube resets daily API quota at midnight Pacific Time — do not change this timezone. +// One formatter for the whole run: building it per call is measurable work on every request while the quota is exhausted +const quotaDayFormat = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit' +}) -// YouTube resets daily API quota at midnight Pacific Time — do not change this timezone function getQuotaDate(): string { - return new Intl.DateTimeFormat('en-CA', { - timeZone: 'America/Los_Angeles', - year: 'numeric', - month: '2-digit', - day: '2-digit' - }).format(new Date()) + return quotaDayFormat.format(new Date()) } let quotaExhaustedDate: string | null = null export function markQuotaExhausted(): void { - quotaExhaustedDate = getQuotaDate() + const today = getQuotaDate() + if (quotaExhaustedDate === today) return + + quotaExhaustedDate = today + // the one line about it, at the moment it becomes true: every request the flag blocks afterwards stays silent + log.warn('YouTube quota exhausted: requests are paused until midnight Pacific Time') } export function isQuotaExhausted(): boolean { @@ -53,20 +66,18 @@ function write<T>(map: Map<string, Entry<T>>, key: string, value: T, ttl: number if (map.size > MAX_ENTRIES) map.delete(map.keys().next().value as string) } -export function getSearchCache(key: string): Song[] | undefined { +export function getSearchCache(key: string): VideoItem[] | undefined { return read(searches, key) } -export function setSearchCache(key: string, songs: Song[]): void { - write(searches, key, songs, CACHE_LIMITS.SEARCH_CACHE_TTL) +export function setSearchCache(key: string, videos: VideoItem[]): void { + write(searches, key, videos, CACHE_LIMITS.SEARCH_CACHE_TTL) } -export type VideoCacheResult = { song: Song | null; reason: FilterFailureReason | null } - -export function getVideoCache(key: string): VideoCacheResult | undefined { +export function getVideoCache(key: string): VideoItem | null | undefined { return read(videos, key) } -export function setVideoCache(key: string, result: VideoCacheResult): void { - write(videos, key, result, CACHE_LIMITS.VIDEO_CACHE_TTL) +export function setVideoCache(key: string, video: VideoItem | null): void { + write(videos, key, video, CACHE_LIMITS.VIDEO_CACHE_TTL) } diff --git a/src/youtube/client.ts b/src/youtube/client.ts index fce667f..2cc7ab0 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -4,6 +4,7 @@ import { getConfig } from '../config.js' import { Song, AppError, Config, FilterFailureReason } from '../types.js' import { VideoItem } from './types.js' import { isoDurationToSeconds } from './utils.js' +import { isQuotaExhausted, markQuotaExhausted } from './cache.js' export type PlaylistMeta = { id: string @@ -129,12 +130,13 @@ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]) const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 500 +const QUOTA_EXHAUSTED_MESSAGE = 'YouTube API quota exceeded. Quota resets at midnight Pacific Time' + export async function youtube<T>(path: string, params: Record<string, string>, { attempts = MAX_ATTEMPTS }: { attempts?: number } = {}): Promise<T> { const { youtubeApiKey } = getSecrets() - if (!youtubeApiKey) { - throw new AppError('NO_API_KEY', 'YouTube API key is not configured, add it in the control panel') - } + if (!youtubeApiKey) throw new AppError('NO_API_KEY', 'YouTube API key is not configured, add it in the control panel') + if (isQuotaExhausted()) throw new AppError('YOUTUBE_QUOTA', QUOTA_EXHAUSTED_MESSAGE) const url = new URL(`https://www.googleapis.com/youtube/v3/${path}`) @@ -168,7 +170,8 @@ export async function youtube<T>(path: string, params: Record<string, string>, { const reason = data.error?.errors?.[0]?.reason if (reason === 'quotaExceeded') { - throw new AppError('YOUTUBE_QUOTA', 'YouTube API quota exceeded. Quota resets at midnight Pacific Time') + markQuotaExhausted() + throw new AppError('YOUTUBE_QUOTA', QUOTA_EXHAUSTED_MESSAGE) } throw new AppError('YOUTUBE_ERROR', data.error?.message || `YouTube API error ${response.status}`) diff --git a/src/youtube/index.ts b/src/youtube/index.ts index 5b6f949..b7892f5 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -12,13 +12,13 @@ import { fetchVideos } from './client.js' import { normalize } from './utils.js' -import { getSearchCache, setSearchCache, getVideoCache, setVideoCache, isQuotaExhausted, markQuotaExhausted } from './cache.js' +import { getSearchCache, setSearchCache, getVideoCache, setVideoCache } from './cache.js' import { VideoItem, SearchItem, PlaylistItem } from './types.js' import { getConfig } from '../config.js' import { createLogger, describeError } from '../logger.js' -const pendingSearches = new Map<string, Promise<Song[]>>() -const pendingVideos = new Map<string, Promise<Song | null>>() +const pendingSearches = new Map<string, Promise<VideoItem[]>>() +const pendingVideos = new Map<string, Promise<VideoItem | null>>() const pendingPlaylists = new Map<string, Promise<PlaylistFetchResult>>() const PLAYLIST_PAGE_SIZE = 50 const MAX_PLAYLIST_PAGES = 3 @@ -44,58 +44,44 @@ function dedupInFlight<T>(pending: Map<string, Promise<T>>, key: string, run: () return request.finally(() => pending.delete(key)) } -function filtersVersion(): string { - const c = getConfig() - return `${c.minViews}:${c.minDurationSeconds}:${c.maxDurationSeconds}:${c.regionCode}:${c.allowShorts}:${c.allowLiveStreams}:${c.contentMode}` +function searchCacheKey(normalizedQuery: string): string { + return `${getConfig().contentMode}|${normalizedQuery}` } export async function getVideoById(videoId: string, bypassFilters = false): Promise<Song | null> { - const cacheKey = bypassFilters ? `raw:${videoId}` : `${filtersVersion()}|${videoId}` - const cached = bypassFilters ? undefined : getVideoCache(cacheKey) + // the raw video is cached without the filters: they are applied on every read, so their change keeps the entry valid + const cached = getVideoCache(videoId) + const video = cached !== undefined ? cached : await dedupInFlight(pendingVideos, videoId, () => fetchVideoById(videoId)) - if (cached !== undefined) { - if (cached.song === null && cached.reason) { - throwFilterError(cached.reason, getConfig()) - } - return cached.song - } - - return dedupInFlight(pendingVideos, cacheKey, () => fetchVideoById(videoId, bypassFilters)) -} - -async function fetchVideoById(videoId: string, bypassFilters: boolean): Promise<Song | null> { - const [video] = await withYouTubeErrorHandling('YouTube API', () => fetchVideos([videoId])) - - if (!video) { - if (!bypassFilters) setVideoCache(`${filtersVersion()}|${videoId}`, { song: null, reason: null }) - return null - } + if (!video) return null const song = videoToSong(video) + if (bypassFilters) return song - if (!bypassFilters) { - const reason = getFilterFailureReason(song, video) - if (reason) { - setVideoCache(`${filtersVersion()}|${videoId}`, { song: null, reason }) - throwFilterError(reason, getConfig()) - } - } - - if (!bypassFilters) setVideoCache(`${filtersVersion()}|${videoId}`, { song, reason: null }) + const reason = getFilterFailureReason(song, video) + if (reason) throwFilterError(reason, getConfig()) return song } +async function fetchVideoById(videoId: string): Promise<VideoItem | null> { + const [video] = await withYouTubeErrorHandling('YouTube API', () => fetchVideos([videoId])) + + setVideoCache(videoId, video ?? null) + return video ?? null +} + export async function searchSongs(query: string, bypassFilters = false): Promise<Song[]> { const normalizedQuery = normalize(query) if (!normalizedQuery) return [] - const cacheKey = bypassFilters ? `raw:${normalizedQuery}` : `${filtersVersion()}|${normalizedQuery}` - const cached = bypassFilters ? undefined : getSearchCache(cacheKey) - if (cached !== undefined) return cached + const cacheKey = searchCacheKey(normalizedQuery) + const cached = getSearchCache(cacheKey) + if (cached !== undefined) return mapValidSongs(cached, bypassFilters) const trimmedQuery = query.trim() - return dedupInFlight(pendingSearches, cacheKey, () => performSearch(trimmedQuery, bypassFilters)) + const videos = await dedupInFlight(pendingSearches, cacheKey, () => performSearch(trimmedQuery, cacheKey)) + return mapValidSongs(videos, bypassFilters) } function mapValidSongs(videos: VideoItem[], bypassFilters = false): Song[] { @@ -105,52 +91,34 @@ function mapValidSongs(videos: VideoItem[], bypassFilters = false): Song[] { .map(({ song }) => song) } -async function performSearch(query: string, bypassFilters: boolean): Promise<Song[]> { - if (isQuotaExhausted()) { - log.warn('YouTube quota exhausted for today') - throw new AppError('YOUTUBE_QUOTA', 'YouTube API quota exceeded. Quota resets at midnight Pacific Time') - } - - let search: { items: SearchItem[] } - try { - search = await youtube<{ items: SearchItem[] }>( - 'search', - { - part: 'snippet', - q: query, - type: 'video', - // 'any' searches every category; clips filed outside Music are reachable by link only in 'music' mode - ...(getConfig().contentMode === 'music' ? { videoCategoryId: MUSIC_CATEGORY_ID } : {}), - videoEmbeddable: 'true', - videoSyndicated: 'true', - maxResults: '20', - order: 'relevance', - safeSearch: 'moderate' - }, - { attempts: 1 } - ) - } catch (error) { - if (error instanceof AppError && error.code === 'YOUTUBE_QUOTA') { - markQuotaExhausted() - } - throw error - } +async function performSearch(query: string, cacheKey: string): Promise<VideoItem[]> { + const search = await youtube<{ items: SearchItem[] }>( + 'search', + { + part: 'snippet', + q: query, + type: 'video', + ...(getConfig().contentMode === 'music' ? { videoCategoryId: MUSIC_CATEGORY_ID } : {}), + videoEmbeddable: 'true', + videoSyndicated: 'true', + maxResults: '20', + order: 'relevance', + safeSearch: 'moderate' + }, + { attempts: 1 } + ) const ids = (search.items ?? []).map((item) => item.id?.videoId).filter((id): id is string => Boolean(id)) - const normalizedQuery = normalize(query) - const cacheKey = bypassFilters ? `raw:${normalizedQuery}` : `${filtersVersion()}|${normalizedQuery}` if (!ids.length) { - if (!bypassFilters) setSearchCache(cacheKey, []) + setSearchCache(cacheKey, []) return [] } const videos = await fetchVideos(ids) - const songs = mapValidSongs(videos, bypassFilters) - - if (!bypassFilters) setSearchCache(cacheKey, songs) + setSearchCache(cacheKey, videos) - return songs + return videos } /** diff --git a/tests/backend/error-codes.test.ts b/tests/backend/error-codes.test.ts index d8a7ebf..6c42818 100644 --- a/tests/backend/error-codes.test.ts +++ b/tests/backend/error-codes.test.ts @@ -4,7 +4,14 @@ import { translateErrorCode } from '../../src/i18n.js' test('API error codes map to their own message', async (t) => { await t.test('INVALID_REQUEST no longer pretends to be "username required"', () => { - assert.equal(translateErrorCode('en', 'INVALID_REQUEST'), null) + assert.notEqual(translateErrorCode('en', 'INVALID_REQUEST'), 'Username is required') + assert.equal(translateErrorCode('en', 'INVALID_REQUEST'), 'Invalid request') + assert.equal(translateErrorCode('ru', 'INVALID_REQUEST'), 'Некорректный запрос') + }) + + await t.test('a Twitch API error carries the message from Twitch itself', () => { + assert.equal(translateErrorCode('en', 'TWITCH_API_ERROR', { message: 'The custom reward title must be unique' }), 'Twitch API error: The custom reward title must be unique') + assert.equal(translateErrorCode('ru', 'TWITCH_API_ERROR', { message: 'boom' }), 'Ошибка Twitch API: boom') }) await t.test('specific codes have their own text in both languages', () => { diff --git a/tests/backend/i18n.test.ts b/tests/backend/i18n.test.ts index fbda78c..9886668 100644 --- a/tests/backend/i18n.test.ts +++ b/tests/backend/i18n.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { t, translateErrorCode } from '../../src/i18n.js' +import { t, hasKey, translateErrorCode, translateApiError } from '../../src/i18n.js' test('t()', async (test) => { await test.test('returns the English string for a known key', () => { @@ -11,8 +11,14 @@ test('t()', async (test) => { assert.equal(t('xx', 'activity.title'), 'Activity') }) - await test.test('returns null for a genuinely unknown key', () => { - assert.equal(t('en', 'this.key.does.not.exist'), null) + await test.test('returns the key itself for a genuinely unknown key', () => { + assert.equal(t('en', 'this.key.does.not.exist'), 'this.key.does.not.exist') + }) + + await test.test('hasKey() tells a missing key from a text that resolved', () => { + assert.equal(hasKey('en', 'this.key.does.not.exist'), false) + assert.equal(hasKey('en', 'activity.title'), true) + assert.equal(hasKey('ru', 'activity.title'), true) }) await test.test('interpolates a plain param', () => { @@ -39,4 +45,8 @@ test('translateErrorCode()', async (test) => { await test.test('an unknown code returns null', () => { assert.equal(translateErrorCode('en', 'NOT_A_REAL_CODE'), null) }) + + await test.test('a code with no translation is answered with the generic message, not the key', () => { + assert.equal(translateApiError('en', 'NOT_A_REAL_CODE'), 'Something went wrong, see the log for details') + }) }) diff --git a/tests/backend/integrations/twitch/chat-commands.test.ts b/tests/backend/integrations/twitch/chat-commands.test.ts index df24aad..15c8510 100644 --- a/tests/backend/integrations/twitch/chat-commands.test.ts +++ b/tests/backend/integrations/twitch/chat-commands.test.ts @@ -13,7 +13,7 @@ const { getState } = await import('../../../../src/player.js') const { _test, _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') const { clearTwitchOAuthState } = await import('../../../../src/secrets.js') -import type { TwitchChatMessage } from '../../../../src/integrations/twitch/types.js' +import type { TwitchChatMessage, TwitchChat } from '../../../../src/integrations/twitch/types.js' function message(overrides: Partial<TwitchChatMessage> = {}): TwitchChatMessage { return { @@ -214,6 +214,72 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => }) }) +test('a chat reply that waits for a connection', async (t) => { + await t.test('is sent as soon as the chat reconnects, not a fixed minute later', async () => { + // short limits, so a lost reply does not take a minute to give up; the real ones are 5s/60s + _test.setChatWaitLimits({ start: 100, reconnect: 300 }) + + const sent: string[] = [] + let connected = false + _test.setChat({ + isConnected: () => connected, + sendMessage: (text: string) => { + sent.push(text) + return Promise.resolve() + }, + disconnect: () => Promise.resolve() + } as unknown as TwitchChat) + + try { + hydrateQueue({ + current: { videoId: 'abc', title: 'Test Song', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' } + }) + + // the chat comes back shortly: the reply must go out then, not after the whole reconnect window + setTimeout(() => (connected = true), 50) + + const startedAt = Date.now() + await _test.handleChatMessage(message({ text: '!sg now' })) + const waitedMs = Date.now() - startedAt + + assert.equal(sent.length, 1, 'the reply went out once the chat was back') + assert.ok(waitedMs < 1000, `the reply waited ${waitedMs}ms for a reconnection that took 50ms`) + } finally { + _test.setChat(null) + } + }) + + await t.test('is dropped, without hanging, when the chat does not come back within its window', async () => { + // short limits, so the "gave up" path does not take a minute; the real ones are 5s/60s + _test.setChatWaitLimits({ start: 100, reconnect: 300 }) + + const sent: string[] = [] + _test.setChat({ + isConnected: () => false, + sendMessage: (text: string) => { + sent.push(text) + return Promise.resolve() + }, + disconnect: () => Promise.resolve() + } as unknown as TwitchChat) + + try { + hydrateQueue({ + current: { videoId: 'abc', title: 'Test Song', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' } + }) + + const startedAt = Date.now() + await _test.handleChatMessage(message({ text: '!sg now' })) + const waitedMs = Date.now() - startedAt + + assert.ok(waitedMs < 2000, `the wait ended after ${waitedMs}ms, not by hanging`) + assert.equal(sent.length, 0, 'no reply without a connection') + } finally { + _test.setChat(null) + } + }) +}) + test('handleChatMessage() - custom configured commands', async (t) => { await t.test('a renamed command is matched by its new text, not the old default', async () => { updateTwitchConfig({ chatCommands: { skip: { command: '!qskip' } } }) diff --git a/tests/backend/integrations/twitch/device-auth.test.ts b/tests/backend/integrations/twitch/device-auth.test.ts index 760107f..bb41fd8 100644 --- a/tests/backend/integrations/twitch/device-auth.test.ts +++ b/tests/backend/integrations/twitch/device-auth.test.ts @@ -10,6 +10,7 @@ const { initializeTwitchIntegration, startDeviceAuthorization, disconnect, _test await import('../../../../src/integrations/twitch/index.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const { clearTwitchOAuthState, getSecrets, getPublicSecretsView } = await import('../../../../src/secrets.js') +const { onNotice } = await import('../../../../src/state-events.js') import type { TwitchClient } from '../../../../src/integrations/twitch/client.js' const originalFetch = globalThis.fetch @@ -23,6 +24,7 @@ let polledCodes: string[] = [] let userInfoRequests = 0 let revokedTokens: string[] = [] let tokenGate: Promise<void> | null = null // holds back the answer to a poll of code-1 +let userInfoRefusal: Response | null = null // when set, /helix/users answers with this instead of a user // A Twitch that hands out the codes code-1, code-2, ... A login ends when the test puts its code into `granted` beforeEach(async () => { @@ -33,6 +35,7 @@ beforeEach(async () => { userInfoRequests = 0 revokedTokens = [] tokenGate = null + userInfoRefusal = null globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = String(input) @@ -64,6 +67,7 @@ beforeEach(async () => { if (url.includes('/helix/users')) { userInfoRequests++ + if (userInfoRefusal) return userInfoRefusal return json({ data: [{ id: '1', login: 'amy', display_name: 'Amy', profile_image_url: '' }] }) } @@ -194,6 +198,45 @@ test('device authorization', async (t) => { }) }) +test('a login whose account details cannot be fetched is rolled back and reported to the panel', async (t) => { + await t.test('Twitch is told to forget the tokens, nothing stays on disk, and the panel learns why', async () => { + _test.setRetryDelay(1) + // a 403 is a refusal that a retry does not change: it must not be repeated + userInfoRefusal = json({ message: 'not yours' }, 403) + + const notices: Array<{ type: string; code?: string; params?: Record<string, unknown> }> = [] + const off = onNotice((notice) => notices.push(notice)) + + await startDeviceAuthorization() + granted.add('code-1') + await sleep(250) + + assert.equal(userInfoRequests, 1, 'a hopeless 4xx is not retried') + assert.deepEqual(revokedTokens.sort(), ['a', 'r'], 'Twitch is told to forget the tokens it just gave') + assert.equal(getSecrets().twitch.tokenData, null, 'a revoked token does not stay in secrets.json for the next start') + assert.equal(getPublicSecretsView().twitch.connected, false) + assert.ok( + notices.some((notice) => notice.type === 'error' && notice.code === 'TWITCH_API_ERROR' && notice.params?.message === 'not yours'), + 'the panel is told the error that ended the login' + ) + + off() + }) + + await t.test('a passing failure is retried while attempts are left, then rolled back the same way', async () => { + _test.setRetryDelay(1) + userInfoRefusal = json({ message: 'having a moment' }, 503) + + await startDeviceAuthorization() + granted.add('code-1') + await sleep(250) + + assert.equal(userInfoRequests, 3, 'a 5xx is worth another attempt, a 4xx is not') + assert.deepEqual(revokedTokens.sort(), ['a', 'r']) + assert.equal(getSecrets().twitch.tokenData, null) + }) +}) + const event = (id: string, userInput = '') => ({ id, user_name: 'viewer', user_input: userInput, reward: { id: 'reward-1', title: 'Song request' } }) test('the same redemption delivered twice is taken once', async (t) => { diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index 5fe3c81..2da4f53 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -53,6 +53,11 @@ test('isRetryable()', async (t) => { assert.equal(isRetryable(new DOMException('timed out', 'TimeoutError')), true) }) + await t.test('a plain error that is not a network failure is a bug, and is not repeated', () => { + assert.equal(isRetryable(new Error('a bug of ours')), false) + assert.equal(isRetryable('not even an error'), false) + }) + await t.test('429 and 5xx are repeated', () => { for (const status of [429, 500, 502, 503]) assert.equal(isRetryable(api(status)), true, String(status)) }) diff --git a/tests/backend/persist.test.ts b/tests/backend/persist.test.ts index feab4a4..cd2ca5e 100644 --- a/tests/backend/persist.test.ts +++ b/tests/backend/persist.test.ts @@ -273,6 +273,85 @@ test('debounce timing', async (t) => { }) }) +test('saveNow()', async (t) => { + await t.test('writes the file at once, without waiting for the debounce', async () => { + const path = filePath() + const store = createFileStore<{ n: number }>(path) + + await store.saveNow( + () => ({ n: 1 }), + () => {} + ) + + assert.deepEqual(readJson(path), { n: 1 }) + }) + + await t.test('joins the chain: a save scheduled before it is waited for, not raced, and both land once each', async () => { + const path = filePath() + const store = createFileStore<{ n: number }>(path) + const order: string[] = [] + + store.scheduleSave( + () => { + order.push('scheduled') + return { n: 1 } + }, + () => {} + ) + await store.saveNow( + () => { + order.push('now') + return { n: 2 } + }, + () => {} + ) + await store.flush() + + assert.deepEqual(order, ['now', 'scheduled'], 'one write at a time: the .tmp file is never shared by two saves') + assert.deepEqual(readJson(path), { n: 1 }, 'the debounced write ran last, with its own data') + }) + + await t.test('flush() waits for a saveNow that is still on the chain', async () => { + const path = filePath() + const store = createFileStore<{ n: number }>(path) + + void store.saveNow( + () => ({ n: 7 }), + () => {} + ) + await store.flush() + + assert.deepEqual(readJson(path), { n: 7 }, 'the exit flush did not leave before this save was written') + }) + + await t.test('an error is reported through onError, and a later save still works', async () => { + const badDir = join(testDir, `not-writable-${++counter}`) + mkdirSync(badDir) + const path = join(badDir, 'sub') // a directory in place of the file makes the write fail + mkdirSync(path) + + const store = createFileStore<{ n: number }>(path) + const errors: unknown[] = [] + + await assert.doesNotReject( + store.saveNow( + () => ({ n: 1 }), + (error) => errors.push(error) + ) + ) + assert.equal(errors.length, 1) + + const goodPath = filePath() + const goodStore = createFileStore<{ n: number }>(goodPath) + goodStore.scheduleSave( + () => ({ n: 2 }), + () => {} + ) + await goodStore.flush() + assert.deepEqual(readJson(goodPath), { n: 2 }) + }) +}) + test('flushAllStores()', async (t) => { await t.test('flushes every store created so far, including ones with nothing pending', async () => { const pathA = filePath() diff --git a/tests/backend/routes/queue.test.ts b/tests/backend/routes/queue.test.ts index 92d68b3..08cd28a 100644 --- a/tests/backend/routes/queue.test.ts +++ b/tests/backend/routes/queue.test.ts @@ -1,11 +1,15 @@ import { test, beforeEach, after } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' +import { cpSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import type { AddressInfo } from 'node:net' -process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) +// the answers are localized: the real locale files are copied into the throwaway working directory +// (copied, not symlinked: a symlink needs extra privileges on Windows) +const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) +cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) +process.chdir(workDir) const express = (await import('express')).default const { router } = await import('../../../src/routes/queue.js') diff --git a/tests/backend/youtube/quota.no-key.test.ts b/tests/backend/youtube/quota.no-key.test.ts deleted file mode 100644 index a24c4bc..0000000 --- a/tests/backend/youtube/quota.no-key.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { test, beforeEach } from 'node:test' -import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) - -const { CACHE_LIMITS, getSearchCache, setSearchCache, getVideoCache, setVideoCache, reserveSearchQuota, releaseSearchQuota, getSearchesToday } = - await import('../../../src/youtube/cache.js') - -const song = { - videoId: 'video-1', - title: 'Test song', - channelTitle: 'Test channel' -} as any - -const videoResult = { - song, - reason: null -} - -beforeEach(() => { - // The cache module keeps its Maps between tests, so use unique keys. -}) - -test('search cache stores and returns songs', () => { - const key = `search-${Date.now()}-1` - setSearchCache(key, [song]) - assert.deepEqual(getSearchCache(key), [song]) -}) - -test('search cache returns undefined for a missing key', () => { - assert.equal(getSearchCache(`missing-${Date.now()}`), undefined) -}) - -test('video cache stores and returns result', () => { - const key = `video-${Date.now()}-1` - setVideoCache(key, videoResult) - assert.deepEqual(getVideoCache(key), videoResult) -}) - -test('video cache returns undefined for a missing key', () => { - assert.equal(getVideoCache(`missing-${Date.now()}`), undefined) -}) - -test('search cache expires after its TTL', async () => { - const key = `search-expiry-${Date.now()}` - setSearchCache(key, [song]) - assert.deepEqual(getSearchCache(key), [song]) - const realNow = Date.now - Date.now = () => realNow() + CACHE_LIMITS.SEARCH_CACHE_TTL + 1 - - try { - assert.equal(getSearchCache(key), undefined) - } finally { - Date.now = realNow - } -}) - -test('video cache expires after its TTL', () => { - const key = `video-expiry-${Date.now()}` - setVideoCache(key, videoResult) - assert.deepEqual(getVideoCache(key), videoResult) - const realNow = Date.now - Date.now = () => realNow() + CACHE_LIMITS.VIDEO_CACHE_TTL + 1 - - try { - assert.equal(getVideoCache(key), undefined) - } finally { - Date.now = realNow - } -}) - -test('setting the same search key replaces the existing entry', () => { - const key = `search-replace-${Date.now()}` - const first = [song] - const second = [{ ...song, videoId: 'video-2' }] - setSearchCache(key, first) - setSearchCache(key, second) - assert.deepEqual(getSearchCache(key), second) -}) - -test('search cache keeps at most 200 entries', () => { - const prefix = `search-limit-${Date.now()}-` - - for (let i = 0; i < 201; i++) setSearchCache(`${prefix}${i}`, [song]) - - assert.equal(getSearchCache(`${prefix}0`), undefined) - assert.deepEqual(getSearchCache(`${prefix}200`), [song]) -}) - -test('reserveSearchQuota increments daily search count', () => { - const before = getSearchesToday() - assert.equal(reserveSearchQuota(), true) - assert.equal(getSearchesToday(), before + 1) - releaseSearchQuota() -}) - -test('releaseSearchQuota decreases daily search count', () => { - const before = getSearchesToday() - reserveSearchQuota() - assert.equal(getSearchesToday(), before + 1) - releaseSearchQuota() - assert.equal(getSearchesToday(), before) -}) - -test('releaseSearchQuota never makes quota negative', () => { - while (getSearchesToday() > 0) releaseSearchQuota() - - releaseSearchQuota() - assert.equal(getSearchesToday(), 0) -}) - -test('reserveSearchQuota stops at the daily limit', () => { - const reserved: boolean[] = [] - - while (getSearchesToday() > 0) releaseSearchQuota() - - for (let i = 0; i < CACHE_LIMITS.MAX_DAILY_SEARCHES; i++) reserved.push(reserveSearchQuota()) - - assert.equal(reserved.every(Boolean), true) - assert.equal(getSearchesToday(), CACHE_LIMITS.MAX_DAILY_SEARCHES) - assert.equal(reserveSearchQuota(), false) - assert.equal(getSearchesToday(), CACHE_LIMITS.MAX_DAILY_SEARCHES) - - while (getSearchesToday() > 0) releaseSearchQuota() -}) diff --git a/tests/backend/youtube/quota.test.ts b/tests/backend/youtube/quota.test.ts index 9925a30..86263cc 100644 --- a/tests/backend/youtube/quota.test.ts +++ b/tests/backend/youtube/quota.test.ts @@ -6,16 +6,18 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { searchSongs } = await import('../../../src/youtube/index.js') -const { getSearchesToday, releaseSearchQuota, reserveSearchQuota, setVideoCache, getVideoCache, setSearchCache, getSearchCache, CACHE_LIMITS } = - await import('../../../src/youtube/cache.js') +const { searchSongs, getVideoById } = await import('../../../src/youtube/index.js') +const { markQuotaExhausted, getSearchCache, setSearchCache, getVideoCache, setVideoCache, CACHE_LIMITS } = await import( + '../../../src/youtube/cache.js' +) const { updateSecrets } = await import('../../../src/secrets.js') +const { getConfig, updateConfig } = await import('../../../src/config.js') const realFetch = globalThis.fetch let requestsSent = 0 -function stubSearch(behavior: 'success' | 'network-error' | 'timeout' | 'youtube-error') { +function stubSearch(behavior: 'success' | 'network-error' | 'timeout' | 'youtube-error' | 'quota-exceeded') { requestsSent = 0 globalThis.fetch = async (input, init) => { const url = new URL(String(input instanceof Request ? input.url : input)) @@ -32,6 +34,13 @@ function stubSearch(behavior: 'success' | 'network-error' | 'timeout' | 'youtube }) } + if (behavior === 'quota-exceeded') { + return new Response(JSON.stringify({ error: { message: 'quota', errors: [{ reason: 'quotaExceeded' }] } }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }) + } + return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } @@ -43,165 +52,193 @@ beforeEach(() => { updateSecrets({ youtubeApiKey: 'test-key' }) }) -test('search quota is only consumed by a request that actually reached YouTube', async (t) => { - await t.test('a network failure releases the reserved search quota', async () => { - stubSearch('network-error') - const before = getSearchesToday() - await assert.rejects(searchSongs(`network fail query ${Math.random()}`)) - assert.equal(getSearchesToday(), before) +const video = (videoId: string, views: number) => ({ + id: videoId, + snippet: { + title: `Song ${videoId}`, + channelTitle: 'Channel', + categoryId: '10', + thumbnails: { medium: { url: 'https://img/x.jpg' } } + }, + contentDetails: { duration: 'PT3M' }, + statistics: { viewCount: String(views) }, + status: { privacyStatus: 'public', embeddable: true } +}) - for (let i = 0; i < 5; i++) { - await assert.rejects(searchSongs(`network fail query ${i} ${Math.random()}`)) - } - assert.equal(getSearchesToday(), before) - }) +// a search that finds one video, and a video lookup that answers with `views` views for it +function stubFoundVideo(videoId: string, views: number): void { + requestsSent = 0 + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) - await t.test('a timeout stays counted: YouTube may have charged the request whose answer got lost', async () => { - stubSearch('timeout') - const before = getSearchesToday() - await assert.rejects(searchSongs(`timeout query ${Math.random()}`)) - assert.equal(getSearchesToday(), before + 1) - }) + requestsSent++ + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) - await t.test('an error answer from YouTube stays counted, and the search is sent once, not three times', async () => { - stubSearch('youtube-error') - const before = getSearchesToday() - await assert.rejects(searchSongs(`api error query ${Math.random()}`)) - assert.equal(getSearchesToday(), before + 1) - assert.equal(requestsSent, 1) + if (url.pathname.endsWith('/search')) return json({ items: [{ id: { videoId } }] }) + return json({ items: [video(videoId, views)] }) + } +} + +test('search cache returns stored videos', async (t) => { + await t.test('stores and returns the raw videos under the key it was given', () => { + const key = `search-${Date.now()}-1` + const raw = [video('test-video', 5)] as never[] + setSearchCache(key, raw) + assert.deepEqual(getSearchCache(key), raw) }) - await t.test('a missing API key sends nothing and costs nothing', async () => { - stubSearch('success') - updateSecrets({ youtubeApiKey: '' }) - const before = getSearchesToday() - await assert.rejects(searchSongs(`no key query ${Math.random()}`)) - assert.equal(getSearchesToday(), before) - assert.equal(requestsSent, 0) + await t.test('returns undefined for a missing key', () => { + assert.equal(getSearchCache(`missing-${Date.now()}`), undefined) }) - await t.test('a successful search consumes one search from the quota, and the daily limit then stops further searches', async () => { - stubSearch('success') - const before = getSearchesToday() - await searchSongs(`unique success query ${Math.random()}`) - assert.equal(getSearchesToday(), before + 1) - - let guard = 0 - while (guard++ < 500) { - try { - await searchSongs(`unique success query ${guard} ${Math.random()}`) - } catch { - // expected after limit - } - } + await t.test('expires after its TTL', () => { + const key = `search-ttl-${Math.random()}` + setSearchCache(key, [] as never[]) - assert.equal(getSearchesToday(), CACHE_LIMITS.MAX_DAILY_SEARCHES) + const realNow = Date.now + Date.now = () => realNow() + CACHE_LIMITS.SEARCH_CACHE_TTL + 1 + + try { + assert.equal(getSearchCache(key), undefined) + } finally { + Date.now = realNow + } }) -}) -test('search quota reservation increments the count', () => { - while (getSearchesToday() > 0) releaseSearchQuota() - const before = getSearchesToday() + await t.test('setting the same key replaces the existing entry', () => { + const key = `search-replace-${Date.now()}` + setSearchCache(key, [video('video-1', 1)] as never[]) + setSearchCache(key, [video('video-2', 1)] as never[]) + assert.deepEqual(getSearchCache(key), [video('video-2', 1)]) + }) - assert.equal(reserveSearchQuota(), true) - assert.equal(getSearchesToday(), before + 1) + await t.test('keeps at most 200 entries', () => { + const prefix = `search-limit-${Date.now()}-` + for (let i = 0; i < 201; i++) setSearchCache(`${prefix}${i}`, [] as never[]) - releaseSearchQuota() - assert.equal(getSearchesToday(), before) + assert.equal(getSearchCache(`${prefix}0`), undefined) + assert.deepEqual(getSearchCache(`${prefix}200`), []) + }) }) -test('search quota reservation stops at the daily limit', () => { - const before = getSearchesToday() - - while (getSearchesToday() < CACHE_LIMITS.MAX_DAILY_SEARCHES) assert.equal(reserveSearchQuota(), true) +test('video cache returns stored videos', async (t) => { + await t.test('stores and returns the raw video, or null for a video that does not exist', () => { + const found = `video-${Date.now()}-1` + const missing = `video-${Date.now()}-2` + setVideoCache(found, video(found, 1)) + setVideoCache(missing, null) - assert.equal(getSearchesToday(), CACHE_LIMITS.MAX_DAILY_SEARCHES) - assert.equal(reserveSearchQuota(), false) - assert.equal(getSearchesToday(), CACHE_LIMITS.MAX_DAILY_SEARCHES) + assert.deepEqual(getVideoCache(found), video(found, 1)) + assert.equal(getVideoCache(missing), null) + }) - while (getSearchesToday() > before) releaseSearchQuota() -}) + await t.test('returns undefined for a missing key', () => { + assert.equal(getVideoCache(`missing-${Date.now()}`), undefined) + }) -test('releasing search quota does not make the count negative', () => { - while (getSearchesToday() > 0) releaseSearchQuota() + await t.test('expires after its TTL', () => { + const key = `video-ttl-${Math.random()}` + setVideoCache(key, null) - releaseSearchQuota() + const realNow = Date.now + Date.now = () => realNow() + CACHE_LIMITS.VIDEO_CACHE_TTL + 1 - assert.equal(getSearchesToday(), 0) + try { + assert.equal(getVideoCache(key), undefined) + } finally { + Date.now = realNow + } + }) }) -test('search cache returns stored songs', async () => { - const { getSearchCache, setSearchCache } = await import('../../../src/youtube/cache.js') +test('a change of the filters does not throw away paid-for requests', async (t) => { + const defaults = structuredClone(getConfig()) - const songs = [ - { - videoId: 'test-video', - title: 'Test song', - channelTitle: 'Test channel' - } - ] as any + t.after(() => updateConfig(defaults)) - const key = `cache-test-${Math.random()}` + await t.test('a cached search is re-filtered with the new settings, without a new request', async () => { + const videoId = `y4search${Date.now()}`.slice(0, 11) + stubFoundVideo(videoId, 1000) + const query = `y4 search query ${Math.random()}` - setSearchCache(key, songs) + updateConfig({ minViews: 0 }) + const loose = await searchSongs(query) + assert.equal(loose.length, 1, 'a 1000-views video passes minViews 0') + assert.equal(requestsSent, 2, 'one search plus one video lookup') - assert.deepEqual(getSearchCache(key), songs) -}) - -test('search cache returns undefined for an unknown key', async () => { - const { getSearchCache } = await import('../../../src/youtube/cache.js') + updateConfig({ minViews: 100000 }) + const strict = await searchSongs(query) + assert.equal(strict.length, 0, 'the same cached result is filtered with the new minViews') + assert.equal(requestsSent, 2, 'no new request: the filters do not invalidate the search cache') + }) - assert.equal(getSearchCache(`missing-${Math.random()}`), undefined) -}) + await t.test('a cached video is re-checked against the new filters, without a new request', async () => { + const videoId = `y4video${Date.now()}`.slice(0, 11) + stubFoundVideo(videoId, 1000) -test('video cache returns stored result', async () => { - const { getVideoCache, setVideoCache } = await import('../../../src/youtube/cache.js') + updateConfig({ minViews: 0 }) + const song = await getVideoById(videoId) + assert.equal(song?.videoId, videoId) + assert.equal(requestsSent, 1, 'one video lookup') - const result = { - song: null, - reason: 'VIDEO_NOT_FOUND' - } as any + updateConfig({ minViews: 100000 }) + await assert.rejects(getVideoById(videoId), (error: { code?: string }) => error.code === 'VIEWS_TOO_LOW') + assert.equal(requestsSent, 1, 'no new request: the filters are applied to the cached raw video') + }) - const key = `video-cache-test-${Math.random()}` + await t.test('a content-mode change is a different search, and is not answered from the other mode\u2019s cache', async () => { + const videoId = `y4mode${Date.now()}`.slice(0, 11) + stubFoundVideo(videoId, 100000) + const query = `y4 mode query ${Math.random()}` - setVideoCache(key, result) + updateConfig({ contentMode: 'any' }) + await searchSongs(query) + assert.equal(requestsSent, 2, 'the first mode does its own search') - assert.deepEqual(getVideoCache(key), result) + updateConfig({ contentMode: 'music' }) + await searchSongs(query) + assert.equal(requestsSent, 4, 'music asks YouTube again: the search request itself is different') + }) }) -test('video cache returns undefined for an unknown key', async () => { - const { getVideoCache } = await import('../../../src/youtube/cache.js') +// the last block in the file: these tests set the quota flag, and it stays set for the process +test('the quota flag gates every request to YouTube, not only searches', async (t) => { + await t.test('a network failure does not exhaust the quota: the next request still goes out', async () => { + stubSearch('network-error') + await assert.rejects(searchSongs(`network fail query ${Math.random()}`), (error: { code?: string }) => error.code === 'YOUTUBE_ERROR') - assert.equal(getVideoCache(`missing-${Math.random()}`), undefined) -}) -test('search cache expires after TTL', () => { - const key = `search-ttl-${Math.random()}` - const songs = [{ videoId: 'test-video' }] as any + stubSearch('success') + await searchSongs(`still works query ${Math.random()}`) + assert.equal(requestsSent, 1, 'the request went out: a failure that never reached YouTube sets no flag') + }) - setSearchCache(key, songs) + await t.test('quotaExceeded from any answer sets the flag, and the next request is blocked at once', async () => { + stubSearch('quota-exceeded') + await assert.rejects(getVideoById('bbbbbbbbbbb'), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') + assert.equal(requestsSent, 1) - const realNow = Date.now - Date.now = () => realNow() + CACHE_LIMITS.SEARCH_CACHE_TTL + 1 + stubSearch('success') + await assert.rejects(searchSongs(`blocked query ${Math.random()}`), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') + assert.equal(requestsSent, 0, 'the flag blocks the request before it goes out') - try { - assert.equal(getSearchCache(key), undefined) - } finally { - Date.now = realNow - } -}) + await assert.rejects(getVideoById('ccccccccccc'), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') + assert.equal(requestsSent, 0, 'also for a link') + }) -test('video cache expires after TTL', () => { - const key = `video-ttl-${Math.random()}` - const result = { song: null, reason: null } + await t.test('a link lookup is blocked while the quota is exhausted', async () => { + stubSearch('success') + markQuotaExhausted() - setVideoCache(key, result) + await assert.rejects(getVideoById('aaaaaaaaaaa'), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') + assert.equal(requestsSent, 0, 'nothing goes out') + }) - const realNow = Date.now - Date.now = () => realNow() + CACHE_LIMITS.VIDEO_CACHE_TTL + 1 + await t.test('a playlist update is blocked the same way', async () => { + stubSearch('success') + const { fetchPlaylistSongs } = await import('../../../src/youtube/index.js') - try { - assert.equal(getVideoCache(key), undefined) - } finally { - Date.now = realNow - } + await assert.rejects(fetchPlaylistSongs('PLtest1234567890'), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') + assert.equal(requestsSent, 0, 'nothing goes out') + }) }) diff --git a/tests/backend/youtube/request.test.ts b/tests/backend/youtube/request.test.ts index f121ea4..9f2e332 100644 --- a/tests/backend/youtube/request.test.ts +++ b/tests/backend/youtube/request.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { youtube, RequestNotSentError } = await import('../../../src/youtube/client.js') +const { youtube } = await import('../../../src/youtube/client.js') const { updateSecrets } = await import('../../../src/secrets.js') updateSecrets({ youtubeApiKey: 'test-key' }) @@ -46,22 +46,4 @@ test('youtube() requests', async (t) => { await assert.rejects(youtube('videos', { id: 'x' }), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') assert.equal(calls, 1) }) - - await t.test('a network failure that never reached YouTube is marked as not sent', async () => { - stub(new TypeError('fetch failed')) - await assert.rejects(youtube('search', { q: 'x' }, { attempts: 1 }), (error: unknown) => error instanceof RequestNotSentError) - }) - - await t.test('a timeout is not marked as not sent: YouTube may have received the request', async () => { - stub(new DOMException('timed out', 'TimeoutError')) - await assert.rejects(youtube('search', { q: 'x' }, { attempts: 1 }), (error: unknown) => !(error instanceof RequestNotSentError)) - }) - - await t.test('a missing key is marked as not sent and nothing goes out', async () => { - updateSecrets({ youtubeApiKey: '' }) - stub(json(200, {})) - await assert.rejects(youtube('videos', { id: 'x' }), (error: unknown) => error instanceof RequestNotSentError) - assert.equal(calls, 0) - updateSecrets({ youtubeApiKey: 'test-key' }) - }) }) diff --git a/tests/frontend/control/notices.test.js b/tests/frontend/control/notices.test.js index b7c01ff..c527579 100644 --- a/tests/frontend/control/notices.test.js +++ b/tests/frontend/control/notices.test.js @@ -24,4 +24,16 @@ test('showNotice()', async (t) => { assert.equal(panel.toasts().join('|'), before) }) + + await t.test('shows an error notice with the translation of its code, filled with its params', () => { + showNotice({ type: 'error', level: 'error', code: 'TWITCH_API_ERROR', params: { message: 'The custom reward title must be unique' } }) + + assert.ok(panel.toasts().some((toast) => toast.includes('Twitch API error: The custom reward title must be unique'))) + }) + + await t.test('an error notice with a code it has no translation for falls back to the generic message', () => { + showNotice({ type: 'error', level: 'error', code: 'NOT_A_REAL_CODE' }) + + assert.ok(panel.toasts().some((toast) => toast.includes('Something went wrong'))) + }) }) From e69a376a2d60705e60eb7dd190b05225edfc25f8 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:50:23 +0700 Subject: [PATCH 05/22] Harden persistence and avoid redundant state updates Persistence: - retry transient file-write failures - centralize save-error reporting - validate top-level data when loading persisted files - check data-directory writability at startup - move queue state into the data directory Settings: - skip file writes and state-update events when settings remain unchanged --- src/activity.ts | 7 +- src/blocklist.ts | 7 +- src/config-helper.ts | 20 +-- src/persist.ts | 85 +++++++++++-- src/playlists.ts | 7 +- src/secrets.ts | 5 +- src/server.ts | 3 + src/settings.ts | 4 +- src/state-file.ts | 10 +- tests/backend/config.test.ts | 20 ++- tests/backend/fallback.restart.test.ts | 7 +- tests/backend/persist.test.ts | 157 +++++++++++++++++++++++- tests/backend/settings.test.ts | 51 +++++++- tests/backend/state-file.import.test.ts | 4 +- tests/backend/state-file.test.ts | 19 ++- 15 files changed, 347 insertions(+), 59 deletions(-) diff --git a/src/activity.ts b/src/activity.ts index f8a222e..6fdf5a4 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,6 +1,6 @@ import { ActivityEntry, ActivityReasonCode, QueueItem } from './types.js' import { dataPath, createFileStore, loadList } from './persist.js' -import { createLogger, describeError } from './logger.js' +import { createLogger } from './logger.js' import { emit } from './state-events.js' const ACTIVITY_LIMIT = 100 @@ -32,10 +32,7 @@ const entries = (): ActivityEntry[] => (loaded ??= loadList(store, isActivityEnt const log = createLogger('ACTIVITY') function save(): void { - store.scheduleSave( - () => entries(), - (error) => log.error(`Failed to save: ${describeError(error)}`) - ) + store.scheduleSave(() => entries()) } export function logActivity(entry: Omit<ActivityEntry, 'at'>): void { diff --git a/src/blocklist.ts b/src/blocklist.ts index d8ae035..05803fb 100644 --- a/src/blocklist.ts +++ b/src/blocklist.ts @@ -1,5 +1,5 @@ import { dataPath, createFileStore, loadList } from './persist.js' -import { createLogger, describeError } from './logger.js' +import { createLogger } from './logger.js' import { isValidVideoId } from './youtube/url.js' import { emit } from './state-events.js' import type { Blockable } from './types.js' @@ -37,10 +37,7 @@ function ensureLoaded(): { list: BlockedTrack[]; ids: Set<string> } { } function save(): void { - store.scheduleSave( - () => ensureLoaded().list, - (error) => log.error(`Failed to save: ${describeError(error)}`) - ) + store.scheduleSave(() => ensureLoaded().list) } export function getBlockedTracks(): BlockedTrack[] { diff --git a/src/config-helper.ts b/src/config-helper.ts index 8154258..deb85fa 100644 --- a/src/config-helper.ts +++ b/src/config-helper.ts @@ -1,5 +1,6 @@ +import { isDeepStrictEqual } from 'node:util' import { createFileStore, deepMerge, type DeepPartial } from './persist.js' -import { createLogger, describeError } from './logger.js' +import { createLogger } from './logger.js' const FIELD = Symbol('field-rule') @@ -86,11 +87,7 @@ export function createConfigModule<T extends object>(options: { let config: T | null = null const current = (): T => (config ??= loadChecked()) - const save = () => - store.scheduleSave( - () => current(), - (error) => log.error(`Failed to save ${store.path()}: ${describeError(error)}`) - ) + const save = () => store.scheduleSave(() => current()) function validateConfigUpdates(updates: unknown, against: T = current()) { const { clean: validated, rejected } = validateUpdates(schema, updates) @@ -101,9 +98,12 @@ export function createConfigModule<T extends object>(options: { function updateConfig(updates: DeepPartial<T>) { const { clean, rejected } = validateConfigUpdates(updates) - config = deepMerge(current(), clean) - save() - return { config: structuredClone(config), rejected } + const previous = current() + const next = deepMerge(previous, clean) + const changed = !isDeepStrictEqual(next, previous) + config = next + if (changed) save() + return { config: structuredClone(next), rejected, changed } } function restoreConfig(snapshot: T): void { @@ -116,7 +116,7 @@ export function createConfigModule<T extends object>(options: { validateConfigUpdates, updateConfig, restoreConfig, - saveNow: async (onError: (error: unknown) => void) => { + saveNow: async (onError?: (error: unknown) => void) => { await store.saveNow(() => current(), onError) } } diff --git a/src/persist.ts b/src/persist.ts index 00c3d12..225d90c 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -1,10 +1,17 @@ import { existsSync, readFileSync, renameSync } from 'node:fs' -import { mkdir, rename, writeFile } from 'node:fs/promises' +import { mkdir, open, rename, rm } from 'node:fs/promises' +import { setTimeout as sleep } from 'node:timers/promises' import { dirname, join } from 'node:path' import { getCacheDir, getDataDir } from './runtime.js' import { createLogger, describeError } from './logger.js' const SAVE_DEBOUNCE_MS = 250 +const SAVE_ATTEMPTS = 3 +let saveRetryDelayMs = 150 + +const RETRYABLE_SAVE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']) + +let renameFile: typeof rename = rename const log = createLogger('PERSIST') @@ -16,6 +23,12 @@ export type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartia const isPlain = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) +const kindOf = (value: unknown): string => { + if (value === null) return 'null' + if (Array.isArray(value)) return 'list' + return isPlain(value) ? 'object' : typeof value +} + // Two plain objects are merged key by key; anything else (a list, a null, a value of another kind) is taken from `data` as it is export function deepMerge<T>(defaults: T, data: DeepPartial<T>): T { if (!isPlain(defaults) || !isPlain(data)) return data as T @@ -46,7 +59,9 @@ const activeStoreFlushers: Array<() => Promise<void>> = [] export function createFileStore<T>(location: string | (() => string)) { const resolvePath = typeof location === 'function' ? location : () => location - type SaveJob = { getData: () => T; onError: (error: unknown) => void } + type SaveJob = { getData: () => T; onError?: (error: unknown) => void } + + const reportError = (error: unknown): void => log.error(`Failed to save ${resolvePath()}: ${describeError(error)}`) let saveTimer: ReturnType<typeof setTimeout> | null = null let saveChain: Promise<void> = Promise.resolve() @@ -57,8 +72,12 @@ export function createFileStore<T>(location: string | (() => string)) { if (!existsSync(filePath)) return defaults try { - const data = JSON.parse(readFileSync(filePath, 'utf8')) as Partial<T> - return deepMerge(defaults, data) + const data: unknown = JSON.parse(readFileSync(filePath, 'utf8')) + if (kindOf(data) !== kindOf(defaults)) { + log.warn(`${filePath} holds a ${kindOf(data)} where a ${kindOf(defaults)} belongs, using the defaults`) + return defaults + } + return deepMerge(defaults, data as DeepPartial<T>) } catch (error) { log.error(`Failed to load ${filePath}: ${describeError(error)}`) setAside(filePath) @@ -70,13 +89,30 @@ export function createFileStore<T>(location: string | (() => string)) { const filePath = resolvePath() const tmpPath = `${filePath}.tmp` - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(tmpPath, JSON.stringify(getData()), 'utf8') - await rename(tmpPath, filePath) + for (let attempt = 1; attempt <= SAVE_ATTEMPTS; attempt++) { + try { + await mkdir(dirname(filePath), { recursive: true }) + const handle = await open(tmpPath, 'w') + try { + await handle.writeFile(JSON.stringify(getData()), 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await renameFile(tmpPath, filePath) + return + } catch (error) { + if (attempt === SAVE_ATTEMPTS || !RETRYABLE_SAVE_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { + await rm(tmpPath, { force: true }).catch(() => {}) + throw error + } + await sleep(saveRetryDelayMs) + } + } } - function saveNow(getData: () => T, onError: (error: unknown) => void): Promise<void> { - const settled = saveChain.then(() => persistNow(getData)).catch(onError) + function saveNow(getData: () => T, onError?: (error: unknown) => void): Promise<void> { + const settled = saveChain.then(() => persistNow(getData)).catch(onError ?? reportError) saveChain = settled.then( () => {}, () => {} @@ -85,10 +121,10 @@ export function createFileStore<T>(location: string | (() => string)) { } function enqueue(job: SaveJob): void { - saveChain = saveChain.then(() => persistNow(job.getData)).catch(job.onError) + saveChain = saveChain.then(() => persistNow(job.getData)).catch(job.onError ?? reportError) } - function scheduleSave(getData: () => T, onError: (error: unknown) => void): void { + function scheduleSave(getData: () => T, onError?: (error: unknown) => void): void { pending = { getData, onError } if (saveTimer) clearTimeout(saveTimer) @@ -126,6 +162,33 @@ export function createFileStore<T>(location: string | (() => string)) { } } +export async function ensureDataDirectory(): Promise<void> { + const dir = getDataDir() + const probe = join(dir, '.write-test') + + try { + await mkdir(dir, { recursive: true }) + const handle = await open(probe, 'w') + await handle.close() + await rm(probe, { force: true }) + log.log(`Data directory: ${dir}`) + } catch (error) { + log.error(`The data directory ${dir} is not writable: ${describeError(error)}. Nothing the app saves will survive a restart`) + } +} + +export const _test = { + setRename(next: typeof rename): void { + renameFile = next + }, + resetRename(): void { + renameFile = rename + }, + setSaveRetryDelay(ms: number): void { + saveRetryDelayMs = ms + } +} + /** * Reads a file that holds a list. Valid JSON is not always what the code expects (a file edited by hand, one left by an * older version): what is not a list becomes an empty one, and an item that fails `isItem` is dropped diff --git a/src/playlists.ts b/src/playlists.ts index 522e28f..b4cbd4b 100644 --- a/src/playlists.ts +++ b/src/playlists.ts @@ -1,5 +1,4 @@ import { dataPath, createFileStore, loadList } from './persist.js' -import { createLogger, describeError } from './logger.js' import { emit } from './state-events.js' export type SavedPlaylist = { @@ -10,7 +9,6 @@ export type SavedPlaylist = { addedAt: number } -const log = createLogger('PLAYLISTS') const store = createFileStore<SavedPlaylist[]>(() => dataPath('playlists.json')) // a saved playlist as the panel gets it: with the answer whether it is the one that is played @@ -36,10 +34,7 @@ const current = (): SavedPlaylist[] => (loaded ??= loadList(store, isSavedPlayli function save(): void { emit('playlists') - store.scheduleSave( - () => current(), - (error) => log.error(`Failed to save: ${describeError(error)}`) - ) + store.scheduleSave(() => current()) } export function getPlaylists(): SavedPlaylist[] { diff --git a/src/secrets.ts b/src/secrets.ts index 23d7726..86f03f0 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -3,9 +3,6 @@ import { createConfigModule, field, type FieldRule, type Schema } from './config import { emit } from './state-events.js' import { getTwitchClientId } from './env.js' import { AppError, type TwitchAuthorization, type TwitchSecrets, type TwitchTokenData, type TwitchUserInfo } from './types.js' -import { createLogger, describeError } from './logger.js' - -const log = createLogger('SECRETS') export type Secrets = { youtubeApiKey: string @@ -87,7 +84,7 @@ export function updateTwitchOAuthState(updates: Partial<TwitchSecrets>, immediat // Save token data immediately to avoid losing it on process exit if (immediate && updates.tokenData) { - secrets.saveNow((error) => log.error(`Failed to save secrets: ${describeError(error)}`)) + void secrets.saveNow() } } diff --git a/src/server.ts b/src/server.ts index 413ce03..2967f70 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { getServerConfig, updateServerConfig } from './server-config.js' import { getAppRoot, loadEnvFile } from './runtime.js' +import { ensureDataDirectory } from './persist.js' import { initState } from './state-file.js' import { runStartupTasks } from './startup.js' import { errorHandler } from './error-handler.js' @@ -49,6 +50,8 @@ async function main() { enableFileLogging() installShutdownHandlers() + await ensureDataDirectory() + initState() clearTwitchAuthorization() const twitchClientId = getTwitchClientId() diff --git a/src/settings.ts b/src/settings.ts index 9d996c2..e631837 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -36,8 +36,8 @@ export const getSettings = settings.getConfig export const validateSettingsUpdates = settings.validateConfigUpdates /** Applies the valid part of `updates` and names the fields that were refused (invalid or unknown), like every save of the panel. */ -export function updateSettings(updates: Partial<Settings>): { config: Settings; rejected: string[] } { +export function updateSettings(updates: Partial<Settings>): { config: Settings; rejected: string[]; changed: boolean } { const result = settings.updateConfig(updates) - emit('state') + if (result.changed) emit('state') return result } diff --git a/src/state-file.ts b/src/state-file.ts index ce01c52..d09404d 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -1,4 +1,4 @@ -import { cachePath, createFileStore } from './persist.js' +import { cachePath, createFileStore, dataPath } from './persist.js' import { QueueItem, Song } from './types.js' import { isValidPlaylistId, isValidVideoId } from './youtube/url.js' import { getQueue, getCurrent, hydrateQueue } from './queue.js' @@ -146,7 +146,7 @@ export function sanitizeState(raw: unknown): SanitizedState { return { current, queue, fallback: sanitizeFallback(data.fallback, problems), problems } } -const store = createFileStore<Partial<StateFile>>(() => cachePath('queue-state.json')) +const store = createFileStore<Partial<StateFile>>(() => dataPath('queue-state.json')) const tracksStore = createFileStore<TracksFile>(() => cachePath('fallback-tracks.json')) const log = createLogger('STATE') @@ -163,9 +163,7 @@ function stateSnapshot(): StateFile { let savedTracksVersion = -1 function persistState(): void { - const onError = (error: unknown) => log.error(`Failed to save state: ${describeError(error)}`) - - store.scheduleSave(stateSnapshot, onError) + store.scheduleSave(stateSnapshot) const version = getFallbackSourceVersion() if (version !== savedTracksVersion) { @@ -174,7 +172,7 @@ function persistState(): void { () => ({ sourceTracks: getFallbackSourceTracks() }), (error) => { savedTracksVersion = -1 // not on disk: written again with the next save - onError(error) + log.error(`Failed to save the fallback tracks: ${describeError(error)}`) } ) } diff --git a/tests/backend/config.test.ts b/tests/backend/config.test.ts index 76f1241..821b200 100644 --- a/tests/backend/config.test.ts +++ b/tests/backend/config.test.ts @@ -1,6 +1,6 @@ import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,6 +8,7 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const { getConfig, updateConfig, validateConfigUpdates } = await import('../../src/config.js') +const { flushAllStores } = await import('../../src/persist.js') const DEFAULTS = getConfig() @@ -128,4 +129,21 @@ test('updateConfig()', async (t) => { assert.equal(getConfig().fallbackPlaylist.repeat, false) assert.equal(getConfig().fallbackPlaylist.shuffle, false) }) + + await t.test('an update that changes nothing rewrites no file', async () => { + updateConfig({ minViews: 4242 }) + await flushAllStores() + const file = join(process.cwd(), 'data', 'config.json') + assert.ok(existsSync(file)) + rmSync(file) + + assert.equal(updateConfig({ minViews: 4242 }).changed, false, 'the same value') + assert.equal(updateConfig({}).changed, false, 'an empty update') + assert.equal(updateConfig({ minViews: 4242, nope: 1 }).changed, false, 'a fully rejected update') + await flushAllStores() + + assert.equal(existsSync(file), false, 'config.json was not rewritten') + + assert.equal(updateConfig({ minViews: 4243 }).changed, true, 'a real change') + }) }) diff --git a/tests/backend/fallback.restart.test.ts b/tests/backend/fallback.restart.test.ts index 7c9d3b0..6e33470 100644 --- a/tests/backend/fallback.restart.test.ts +++ b/tests/backend/fallback.restart.test.ts @@ -8,6 +8,7 @@ import { join } from 'node:path' const dir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) process.chdir(dir) mkdirSync(join(dir, 'cache'), { recursive: true }) +mkdirSync(join(dir, 'data'), { recursive: true }) const fallback = await import('../../src/fallback.js') const { loadState } = await import('../../src/state-file.js') @@ -49,7 +50,7 @@ function writeSavedState({ withTrackFile }: { withTrackFile: boolean }): void { const playing = { ...song(ids[9]), requestedBy: 'Playlist', isFallback: true } const fallbackProgress = { order: ids, cursor: 9, playlistId: PLAYLIST, lastRefreshedAt: 1 } - writeFileSync(join(dir, 'cache', 'queue-state.json'), JSON.stringify({ current: playing, queue: [], fallback: fallbackProgress })) + writeFileSync(join(dir, 'data', 'queue-state.json'), JSON.stringify({ current: playing, queue: [], fallback: fallbackProgress })) if (withTrackFile) writeFileSync(join(dir, 'cache', 'fallback-tracks.json'), JSON.stringify({ sourceTracks: ids.map(song) })) else rmSync(join(dir, 'cache', 'fallback-tracks.json'), { force: true }) @@ -101,7 +102,7 @@ test('after a restart the playlist goes on from where it was', async (t) => { await t.test('a saved position that disagrees with the playing track follows the playing track', async () => { writeSavedState({ withTrackFile: true }) writeFileSync( - join(dir, 'cache', 'queue-state.json'), + join(dir, 'data', 'queue-state.json'), JSON.stringify({ current: { ...song(ids[9]), requestedBy: 'Playlist', isFallback: true }, queue: [], fallback: { order: ids, cursor: 2, playlistId: PLAYLIST, lastRefreshedAt: 1 } }) ) stubPlaylist(ids) @@ -114,7 +115,7 @@ test('after a restart the playlist goes on from where it was', async (t) => { await t.test('a track a viewer requested is playing: the playlist keeps its own position', async () => { writeSavedState({ withTrackFile: true }) writeFileSync( - join(dir, 'cache', 'queue-state.json'), + join(dir, 'data', 'queue-state.json'), JSON.stringify({ current: { ...song('viewerreq01'), requestedBy: 'viewer' }, queue: [], fallback: { order: ids, cursor: 4, playlistId: PLAYLIST, lastRefreshedAt: 1 } }) ) stubPlaylist(ids) diff --git a/tests/backend/persist.test.ts b/tests/backend/persist.test.ts index cd2ca5e..4f6f93d 100644 --- a/tests/backend/persist.test.ts +++ b/tests/backend/persist.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' const testDir = mkdtempSync(join(tmpdir(), 'streamqueue-persist-')) process.chdir(testDir) -const { createFileStore, flushAllStores } = await import('../../src/persist.js') +const { createFileStore, flushAllStores, ensureDataDirectory, _test } = await import('../../src/persist.js') let counter = 0 const filePath = () => join(testDir, `store-${++counter}.json`) @@ -56,6 +56,27 @@ test('load()', async (t) => { assert.deepEqual(store.load({ a: 1 }), { a: null }) }) + + await t.test('a top-level value of the wrong kind gives the defaults instead of lying about the type', (t2) => { + t2.mock.method(console, 'warn', () => {}) + + for (const saved of ['null', '"text"', '5', 'true']) { + const path = filePath() + writeFileSync(path, saved) + const store = createFileStore<{ a: number }>(path) + + assert.deepEqual(store.load({ a: 1 }), { a: 1 }, `file holding ${saved}`) + } + }) + + await t.test('an object where a list belongs gives the defaults', (t2) => { + t2.mock.method(console, 'warn', () => {}) + const path = filePath() + writeFileSync(path, JSON.stringify({ a: 1 })) + const store = createFileStore<Array<{ id: string }>>(path) + + assert.deepEqual(store.load([{ id: 'x' }]), [{ id: 'x' }]) + }) }) test('scheduleSave() / flush()', async (t) => { @@ -273,6 +294,140 @@ test('debounce timing', async (t) => { }) }) +test('transient write failures are retried', async (t) => { + const { rename: realRename } = await import('node:fs/promises') + const busy = (): Error & { code: string } => { + const error = new Error('resource busy or locked') as Error & { code: string } + error.code = 'EBUSY' + return error + } + + _test.setSaveRetryDelay(1) + + await t.test('a rename blocked once (an antivirus letting go of the file) succeeds on the second attempt', async () => { + const path = filePath() + const store = createFileStore<{ n: number }>(path) + let calls = 0 + _test.setRename(async (from, to) => { + calls++ + if (calls === 1) throw busy() + return realRename(from, to) + }) + + try { + store.scheduleSave(() => ({ n: 1 })) + await store.flush() + + assert.equal(calls, 2) + assert.deepEqual(readJson(path), { n: 1 }) + } finally { + _test.resetRename() + } + }) + + await t.test('a rename that keeps failing is given up after three attempts and the .tmp is cleaned up', async (t2) => { + t2.mock.method(console, 'error', () => {}) + const path = filePath() + const store = createFileStore<{ n: number }>(path) + let calls = 0 + _test.setRename(async () => { + calls++ + throw busy() + }) + const errors: unknown[] = [] + + try { + store.scheduleSave( + () => ({ n: 1 }), + (error) => errors.push(error) + ) + await store.flush() + + assert.equal(calls, 3) + assert.equal(errors.length, 1) + assert.equal(existsSync(`${path}.tmp`), false) + } finally { + _test.resetRename() + } + }) + + await t.test('a non-transient failure is not retried at all', async () => { + const path = filePath() + const store = createFileStore<{ n: number }>(path) + let calls = 0 + _test.setRename(async () => { + calls++ + const error = new Error('is a directory') as Error & { code: string } + error.code = 'EISDIR' + throw error + }) + const errors: unknown[] = [] + + try { + store.scheduleSave( + () => ({ n: 1 }), + (error) => errors.push(error) + ) + await store.flush() + + assert.equal(calls, 1) + assert.equal(errors.length, 1) + } finally { + _test.resetRename() + } + }) +}) + +test('a store without its own onError reports the failure itself', async (t) => { + const errors = t.mock.method(console, 'error', () => {}) + const badDir = join(testDir, `not-writable-${++counter}`) + mkdirSync(badDir) + const path = join(badDir, 'sub') // a directory in place of the file makes the write fail + mkdirSync(path) + + const store = createFileStore<{ n: number }>(path) + store.scheduleSave(() => ({ n: 1 })) + await store.flush() + + assert.equal(errors.mock.callCount(), 1) + // raw arguments, not JSON.stringify: on Windows JSON-escaping doubles the backslashes in the path + const reported = String(errors.mock.calls[0]?.arguments ?? '') + assert.ok(reported.includes('Failed to save'), reported) + assert.ok(reported.includes(path), reported) +}) + +test('ensureDataDirectory()', async (t) => { + await t.test('creates the directory, checks that it is writable and says where it is', async (t2) => { + const logs = t2.mock.method(console, 'log', () => {}) + + await ensureDataDirectory() + + const dataDir = join(testDir, 'data') + assert.ok(existsSync(dataDir)) + assert.equal(existsSync(join(dataDir, '.write-test')), false, 'the probe file is cleaned up') + const logged = logs.mock.calls.map((call) => call.arguments.join(' ')).join('\n') + assert.ok(logged.includes(`Data directory: ${dataDir}`), logged) + }) + + await t.test('reports a data directory that cannot be created, without throwing', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + const blocked = join(testDir, `blocked-${++counter}`) + mkdirSync(blocked) + writeFileSync(join(blocked, 'data'), 'a file in place of the directory') + + process.chdir(blocked) + try { + await assert.doesNotReject(ensureDataDirectory()) + } finally { + process.chdir(testDir) + } + + assert.equal(errors.mock.callCount(), 1) + const reported = JSON.stringify(errors.mock.calls[0]?.arguments ?? []) + assert.ok(reported.includes('not writable'), reported) + }) +}) + test('saveNow()', async (t) => { await t.test('writes the file at once, without waiting for the debounce', async () => { const path = filePath() diff --git a/tests/backend/settings.test.ts b/tests/backend/settings.test.ts index 57e4dd2..2714b59 100644 --- a/tests/backend/settings.test.ts +++ b/tests/backend/settings.test.ts @@ -1,6 +1,15 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { getSettings, updateSettings, validateSettingsUpdates } from '../../src/settings.js' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// settings.json is written to process.cwd()/data: keep the test away from the real one +process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) + +const { getSettings, updateSettings, validateSettingsUpdates } = await import('../../src/settings.js') +const { on } = await import('../../src/state-events.js') +const { flushAllStores } = await import('../../src/persist.js') test('validateSettingsUpdates()', async (t) => { await t.test('accepts every valid field', () => { @@ -37,3 +46,43 @@ test('updateSettings()', async (t) => { assert.deepEqual(getSettings(), config) }) }) + +test('an update that changes nothing', async (t) => { + await t.test('rewrites no file and wakes no tab', async () => { + updateSettings({ opacity: 42 }) + await flushAllStores() + const file = join(process.cwd(), 'data', 'settings.json') + assert.ok(existsSync(file)) + rmSync(file) + + const events: string[] = [] + const stop = on('state', () => events.push('state')) + + try { + assert.equal(updateSettings({ opacity: 42 }).changed, false, 'the same value') + assert.equal(updateSettings({}).changed, false, 'an empty update') + assert.equal(updateSettings({ opacity: 'nope' as never }).changed, false, 'a fully rejected update') + await flushAllStores() + + assert.equal(existsSync(file), false, 'settings.json was not rewritten') + assert.deepEqual(events, [], 'no state event was emitted') + } finally { + stop() + } + }) + + await t.test('a real change still saves and emits', async () => { + const events: string[] = [] + const stop = on('state', () => events.push('state')) + + try { + assert.equal(updateSettings({ opacity: 43 }).changed, true) + await flushAllStores() + + assert.deepEqual(events, ['state']) + assert.ok(existsSync(join(process.cwd(), 'data', 'settings.json'))) + } finally { + stop() + } + }) +}) diff --git a/tests/backend/state-file.import.test.ts b/tests/backend/state-file.import.test.ts index cbe2175..4fc3a00 100644 --- a/tests/backend/state-file.import.test.ts +++ b/tests/backend/state-file.import.test.ts @@ -7,8 +7,8 @@ import { join } from 'node:path' const dir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) process.chdir(dir) -const stateFile = join(dir, 'cache', 'queue-state.json') -mkdirSync(join(dir, 'cache'), { recursive: true }) +const stateFile = join(dir, 'data', 'queue-state.json') +mkdirSync(join(dir, 'data'), { recursive: true }) const original = JSON.stringify({ current: { videoId: 'aaaaaaaaaaa', title: 'A', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'viewer' }, queue: [] diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index fa7f7f3..89a4d13 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -7,7 +7,8 @@ import { join } from 'node:path' const dir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) process.chdir(dir) mkdirSync(join(dir, 'cache'), { recursive: true }) -const { sanitizeState, sanitizeQueueItem, initState } = await import('../../src/state-file.js') +mkdirSync(join(dir, 'data'), { recursive: true }) +const { sanitizeState, sanitizeQueueItem, initState, loadState } = await import('../../src/state-file.js') const queue = await import('../../src/queue.js') const { flushAllStores } = await import('../../src/persist.js') @@ -186,7 +187,7 @@ test('sanitizeQueueItem()', async (t) => { }) test('initState()', async (t) => { - const stateFile = join(dir, 'cache', 'queue-state.json') + const stateFile = join(dir, 'data', 'queue-state.json') writeFileSync( stateFile, @@ -222,3 +223,17 @@ test('initState()', async (t) => { ) }) }) + +test('loadState() with a damaged state file', async (t) => { + await t.test('a null state file next to a non-empty track file does not crash the load', (t2) => { + t2.mock.method(console, 'warn', () => {}) + // the queue state lives in data/, the track file in cache/ + writeFileSync(join(dir, 'data', 'queue-state.json'), JSON.stringify(null)) + writeFileSync(join(dir, 'cache', 'fallback-tracks.json'), JSON.stringify({ sourceTracks: [song(F1)] })) + + assert.doesNotThrow(() => loadState()) + + assert.equal(queue.getCurrent(), null) + assert.deepEqual(queue.getQueue(), []) + }) +}) From 1f3bad0be3876ac5b415629d76e22f2eac3e1e6d Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:31:30 +0700 Subject: [PATCH 06/22] Centralize API errors and input validation Error handling: - unify route and request failures around `AppError` - centralize HTTP status mapping - introduce shared input validators Request flows: - propagate structured errors through queue and Twitch redemption handlers Testing: - add tests for error handling, validation, and request failure behavior --- src/chat-replies.ts | 6 +- src/error-handler.ts | 14 ++-- src/finish.ts | 13 +-- src/host-check.ts | 2 +- src/http.ts | 77 +++++++++--------- src/integrations/twitch/index.ts | 55 +++++++------ src/local-only.ts | 9 ++- src/logger.ts | 14 +++- src/player.ts | 8 +- src/queue.ts | 38 ++++----- src/routes/blocklist.ts | 14 ++-- src/routes/fallback.ts | 22 +++--- src/routes/index.ts | 2 +- src/routes/player.ts | 8 +- src/routes/playlists.ts | 34 ++++---- src/routes/queue.ts | 54 +++++-------- src/routes/search.ts | 13 ++- src/routes/settings.ts | 10 +-- src/routes/twitch.ts | 36 ++++----- src/sse.ts | 2 +- src/types.ts | 79 +++++++++---------- src/validate.ts | 40 ++++++++++ tests/backend/chat-replies.test.ts | 9 ++- tests/backend/error-handler.test.ts | 34 ++++++++ .../integrations/twitch/redemptions.test.ts | 58 +++++++++++++- tests/backend/routes/blocklist.test.ts | 37 +++++++++ tests/backend/routes/player.test.ts | 3 + tests/backend/routes/playlists.test.ts | 7 ++ tests/backend/routes/queue.security.test.ts | 11 +++ tests/backend/routes/queue.test.ts | 27 +++++++ 30 files changed, 464 insertions(+), 272 deletions(-) create mode 100644 src/validate.ts create mode 100644 tests/backend/routes/blocklist.test.ts diff --git a/src/chat-replies.ts b/src/chat-replies.ts index f036d7a..d114d85 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -1,5 +1,5 @@ import { t, hasKey } from './i18n.js' -import type { PlayerState, FailureReason } from './types.js' +import { AppError, type PlayerState } from './types.js' const truncate = (s: string, max = 40) => { const chars = Array.from(s) @@ -50,9 +50,9 @@ export function buildRedemptionAcceptedMessage( return added.started ? t(locale, 'chat.redemption.acceptedNowPlaying', params) : t(locale, 'chat.redemption.accepted', params) } -// "@user, <reason>, points refunded" - one message per FailureReason, shared by the Channel Points +// "@user, <reason>, points refunded" - one message per refusal reason, shared by the Channel Points // redemption flow (request rejected, or a later playback failure) wherever it needs to notify chat -export function buildRedemptionRejectionMessage(userName: string, reason: FailureReason, locale: string): string { +export function buildRedemptionRejectionMessage(userName: string, reason: AppError, locale: string): string { // only a playback failure names the track (the one that could not be played); a request that was never queued has none const title = typeof reason.params?.title === 'string' ? truncate(reason.params.title) : undefined const params = { user: userName, ...reason.params, ...(title && { title }) } diff --git a/src/error-handler.ts b/src/error-handler.ts index a915266..acfb4b8 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from 'express' -import { fail, failFromError } from './http.js' +import { fail, failFromAppError } from './http.js' import { AppError } from './types.js' import { createLogger, describeError } from './logger.js' @@ -7,19 +7,21 @@ const log = createLogger('SERVER') type HttpError = Error & { status?: number; statusCode?: number; type?: string } +// The single exit for everything the routes throw: a thrown AppError becomes its own code and status, +// a malformed or oversized body its own, and anything else is a bug and stays anonymous to the client export function errorHandler(err: HttpError, _req: Request, res: Response, next: NextFunction): void { if (res.headersSent) { next(err) return } - if (err instanceof AppError) return failFromError(res, err) - if (err.type === 'entity.parse.failed') return fail(res, 'INVALID_JSON', 400) - if (err.type === 'entity.too.large') return fail(res, 'PAYLOAD_TOO_LARGE', 413) + if (err instanceof AppError) return failFromAppError(res, err) + if (err.type === 'entity.parse.failed') return fail(res, 'INVALID_JSON') + if (err.type === 'entity.too.large') return fail(res, 'PAYLOAD_TOO_LARGE') const status = err.status ?? err.statusCode - if (typeof status === 'number' && status >= 400 && status < 500) return fail(res, 'INVALID_REQUEST', status) + if (typeof status === 'number' && status >= 400 && status < 500) return fail(res, 'INVALID_REQUEST', undefined, status) log.error(describeError(err, true)) - fail(res, 'SERVER_ERROR', 500) + fail(res, 'SERVER_ERROR') } diff --git a/src/finish.ts b/src/finish.ts index a99eb06..99e159b 100644 --- a/src/finish.ts +++ b/src/finish.ts @@ -1,10 +1,10 @@ -import type { FailureReason, QueueItem } from './types.js' +import { AppError, QueueItem } from './types.js' import { createLogger, describeError } from './logger.js' type Tracked = NonNullable<QueueItem['channelPointsRedemption']> -export type RedemptionOutcome = { status: 'succeeded' } | { status: 'failed'; reason: FailureReason } -export type ItemOutcome = 'played' | FailureReason +export type RedemptionOutcome = { status: 'succeeded' } | { status: 'failed'; reason: AppError } +export type ItemOutcome = 'played' | AppError const log = createLogger('FINISH') @@ -18,7 +18,7 @@ export function registerRedemptionHandler(next: typeof handler): void { /** * The single place where a track leaves the queue or the player for good. - * 'played' fulfils its Channel Points redemption, any FailureReason cancels it (points refunded, requester told in chat). + * 'played' fulfils its Channel Points redemption, any thrown reason cancels it (points refunded, requester told in chat). * Tracks without a redemption (chat requests, playlist tracks) are ignored. * * The track owns its reward: once closed it is gone from the track, so no other path can close it again. @@ -30,8 +30,9 @@ export function finishItem(item: QueueItem | null | undefined, outcome: ItemOutc // the track owns its reward: once closed it is gone from the track, so no other path can close it again delete item.channelPointsRedemption - const result: RedemptionOutcome = - outcome === 'played' ? { status: 'succeeded' } : { status: 'failed', reason: { ...outcome, params: { title: item.title, ...outcome.params } } } + // the chat reply names the track, whatever reason the caller knew about + const reason = outcome === 'played' ? null : new AppError(outcome.code, outcome.message, { title: item.title, ...outcome.params }) + const result: RedemptionOutcome = reason ? { status: 'failed', reason } : { status: 'succeeded' } try { handler?.(tracked, result) diff --git a/src/host-check.ts b/src/host-check.ts index b118639..390d4fa 100644 --- a/src/host-check.ts +++ b/src/host-check.ts @@ -36,5 +36,5 @@ export function hostCheck(req: Request, res: Response, next: NextFunction): void if (isAllowedHost(req.headers.host) && originAllowed) return next() - fail(res, 'FORBIDDEN_ORIGIN', 403) + fail(res, 'FORBIDDEN_ORIGIN') } diff --git a/src/http.ts b/src/http.ts index 0bb43da..7c2dda1 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,22 +1,13 @@ import { Response } from 'express-serve-static-core' -import { AppError, AppErrorCode, ApiOk, ApiError, ApiErrorCode, ConfigUpdateResponse } from './types.js' +import { AppError, AppErrorCode, ApiOk, ApiError, ConfigUpdateResponse, FilterFailureReason } from './types.js' import { getSettings } from './settings.js' import { translateApiError } from './i18n.js' -import { createLogger, describeError } from './logger.js' - -const log = createLogger('API') export function ok<T extends object>(res: Response, data: T, status = 200): void { const body: ApiOk<T> = { success: true, data } res.status(status).json(body) } -export function fail(res: Response, code: ApiErrorCode, status: number, params?: Record<string, string | number>): void { - const locale = getSettings().locale - const body: ApiError = { success: false, error: translateApiError(locale, code, params), code, params } - res.status(status).json(body) -} - export function sendConfigUpdate<T extends object>(res: Response, config: T, rejected: string[]): void { const data: ConfigUpdateResponse<T> = { config, rejected } @@ -34,10 +25,12 @@ export function sendConfigUpdate<T extends object>(res: Response, config: T, rej params, data } - res.status(400).json(body) + res.status(STATUS_BY_CODE.INVALID_CONFIG ?? 400).json(body) } -const STATUS_BY_CODE: Record<AppErrorCode, number> = { +// the single place that knows which HTTP status an error code answers with; +// the chat-only reasons (playback, track lifecycle) never appear here and fall back to 500 +const STATUS_BY_CODE: Partial<Record<AppErrorCode, number>> = { DUPLICATE: 409, QUEUE_FULL: 409, USER_LIMIT: 409, @@ -49,40 +42,42 @@ const STATUS_BY_CODE: Record<AppErrorCode, number> = { TWITCH_REFRESH_ERROR: 401, TWITCH_NOT_CONNECTED: 400, TWITCH_API_ERROR: 502, - NOT_MUSIC: 404, - NOT_EMBEDDABLE: 404, - DURATION_OUT_OF_RANGE: 404, - VIEWS_TOO_LOW: 404, - REGION_BLOCKED: 404, - NOT_PUBLIC: 404, - AGE_RESTRICTED: 404, - NOT_PLAYABLE: 404, - IS_LIVE: 404, - IS_SHORT: 404, - INVALID_INPUT: 400, TWITCH_NETWORK_ERROR: 502, - TWITCH_UNKNOWN_ERROR: 500 + TWITCH_UNKNOWN_ERROR: 500, + INVALID_INPUT: 400, + INVALID_QUERY: 400, + INVALID_QUERY_RANGE: 400, + INVALID_VIDEO_ID: 400, + USERNAME_REQUIRED: 400, + INVALID_YOUTUBE_URL: 400, + SONG_NOT_FOUND: 404, + QUEUE_ITEM_NOT_FOUND: 404, + NOT_FOUND: 404, + INVALID_PLAYLIST_ID: 400, + PLAYLIST_NOT_FOUND: 404, + INVALID_CONFIG: 400, + LOCAL_ONLY: 403, + FORBIDDEN_ORIGIN: 403, + INVALID_JSON: 400, + PAYLOAD_TOO_LARGE: 413, + INVALID_REQUEST: 400, + SERVER_ERROR: 500, + ...filterStatuses(['NOT_MUSIC', 'NOT_PUBLIC', 'NOT_EMBEDDABLE', 'AGE_RESTRICTED', 'REGION_BLOCKED', 'NOT_PLAYABLE', 'IS_LIVE', 'IS_SHORT', 'DURATION_OUT_OF_RANGE', 'VIEWS_TOO_LOW'], 404) } -export type ErrorInfo = { code: string; status: number; message: string; params?: Record<string, string | number> } - -export function getErrorInfo(error: unknown): ErrorInfo { - if (error instanceof AppError) { - return { code: error.code, status: STATUS_BY_CODE[error.code], message: error.message, params: { ...error.params, message: error.message } } - } - - log.error(`Unexpected error: ${describeError(error, true)}`) - return { code: 'SERVER_ERROR', status: 500, message: 'internal server error' } +function filterStatuses(codes: readonly FilterFailureReason[], status: number): Partial<Record<AppErrorCode, number>> { + return Object.fromEntries(codes.map((code) => [code, status])) } -export function failFromError(res: Response, error: unknown): void { - const info = getErrorInfo(error) - fail(res, info.code as ApiErrorCode, info.status, info.params) +// The one way an error leaves the app: the errorHandler passes the thrown AppError here, the few +// middleware that answer themselves (localOnly, hostCheck, the SSE connection limit) call it directly +export function fail(res: Response, code: AppErrorCode, params?: Record<string, string | number>, status = STATUS_BY_CODE[code] ?? 500): void { + const locale = getSettings().locale + const body: ApiError = { success: false, error: translateApiError(locale, code, params), code, params } + res.status(status).json(body) } -// same as failFromError, for callers that already have a structured { code, params } reason -// instead of the original thrown error (e.g. RequestSongResult's 'error' outcome) -export function failFromReason(res: Response, reason: { code: string; params?: Record<string, string | number> }): void { - const status = STATUS_BY_CODE[reason.code as AppErrorCode] ?? 500 - fail(res, reason.code as ApiErrorCode, status, reason.params) +// AppError's message reaches the panel as the {{message}} parameter (TWITCH_API_ERROR carries Twitch's own text) +export function failFromAppError(res: Response, error: AppError): void { + fail(res, error.code, { ...error.params, message: error.message }) } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 106a6ef..0b7bdbe 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -24,7 +24,7 @@ import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' import { getState, skipCurrent } from '../../player.js' import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish.js' import { AppError } from '../../types.js' -import type { FailureReason, QueueItem } from '../../types.js' +import type { QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { emit, notify } from '../../state-events.js' import { normalizeTwitchError, isNetworkError } from './error.js' @@ -119,7 +119,8 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemption, twitchClient: TwitchClient): Promise<void> { const configuredRewardId = getTwitchConfig().channelPointsRewardId if (!configuredRewardId || event.reward.id !== configuredRewardId) { - log.log(`Ignoring redemption with non-matching reward ID: ${event.reward.id} (configured: ${configuredRewardId || 'none'})`) + // the subscription sees every redemption of the channel, most are other rewards' business + log.debug(`Ignoring redemption with non-matching reward ID: ${event.reward.id} (configured: ${configuredRewardId || 'none'})`) return } @@ -137,35 +138,36 @@ async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemptio const query = event.user_input.trim() if (!query) { - log.error(`Empty song request in redemption: ${event.id}`) - await cancelRedemption(tracked, twitchClient, { code: 'SONG_NOT_FOUND' }) + log.warn(`Empty song request in redemption: ${event.id}`) + await cancelRedemption(tracked, twitchClient, new AppError('SONG_NOT_FOUND', 'the song request was empty')) return } - const result = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) + try { + const added = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) - if (result.outcome !== 'added') { - const reason: FailureReason = - result.outcome === 'invalid-url' ? { code: 'INVALID_YOUTUBE_URL' } : result.outcome === 'not-found' ? { code: 'SONG_NOT_FOUND' } : result.reason + notify({ type: 'viewerRequest', user: event.user_name, title: added.song.title }) - log.error(`Song request failed for redemption ${event.id}: ${result.outcome}`) - await cancelRedemption(tracked, twitchClient, reason) - return - } + // The account can be disconnected while the song is being looked up. The track is queued by now, but there is + // no login left to close the redemption with, so it is left for the streamer like the others + if (!oauth?.isAuthenticated()) { + const detached = detachChannelPointsRedemptions() + log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) + return + } - notify({ type: 'viewerRequest', user: event.user_name, title: result.added.song.title }) + log.log(`Redemption ${event.id} added to queue`) + const locale = getSettings().locale + await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) + } catch (error) { + // a refusal (a duplicate, a limit, a filter) is routine: the points go back and the viewer is told why; + // anything else is an outage, and requestSong has already logged its details + const say = error instanceof AppError ? log.warn : log.error + say(`Song request failed for redemption ${event.id}: ${describeError(error)}`) - // The account can be disconnected while the song is being looked up. The track is queued by now, but there is - // no login left to close the redemption with, so it is left for the streamer like the others - if (!oauth?.isAuthenticated()) { - const detached = detachChannelPointsRedemptions() - log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) - return + const reason = error instanceof AppError ? error : new AppError('SERVER_ERROR', 'the request failed unexpectedly') + await cancelRedemption(tracked, twitchClient, reason) } - - log.log(`Redemption ${event.id} added to queue`) - const locale = getSettings().locale - await replyInChat(buildRedemptionAcceptedMessage(event.user_name, result.added, locale)) } let eventSubStart: Promise<void> | null = null @@ -547,7 +549,7 @@ async function fulfillRedemption(redemption: TrackedRedemption, twitchClient: Tw log.error(`Redemption ${redemption.id} was played but could not be fulfilled, it is left for a manual decision`) } -async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: FailureReason): Promise<void> { +async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: AppError): Promise<void> { const result = await setRedemptionStatus(redemption, twitchClient, 'CANCELED') // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise @@ -639,6 +641,11 @@ export function getClient(): TwitchClient | null { return client } +// The panel is told this instead of a masked clone of the secrets: the routes only need the boolean +export function isTwitchConnected(): boolean { + return oauth !== null && client !== null && oauth.isAuthenticated() && client.getCachedUserInfo() !== null +} + // Export for testing purposes only export const _test = { hasPermission, diff --git a/src/local-only.ts b/src/local-only.ts index 725ee09..62e9309 100644 --- a/src/local-only.ts +++ b/src/local-only.ts @@ -36,5 +36,12 @@ export function localOnly(req: Request, res: Response, next: NextFunction): void return } - fail(res, 'LOCAL_ONLY', 403) + fail(res, 'LOCAL_ONLY') +} + +// The panel asks for unfiltered results two ways (admin: true in a POST body, ?admin=1 in a GET query); +// whichever way it asks, only this machine's own requests may actually skip them +export function wantsBypassFilters(req: Request): boolean { + const asked = (req.body as { admin?: unknown } | undefined)?.admin === true || req.query.admin === '1' + return asked && isLocalAddress(req.socket.remoteAddress) } diff --git a/src/logger.ts b/src/logger.ts index 9be308a..8a4c98e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,7 @@ import { appendFile, mkdir, rename, rm, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' import { getDataDir } from './runtime.js' +import { AppError } from './types.js' type Level = 'debug' | 'info' | 'warn' | 'error' @@ -22,7 +23,18 @@ function timestamp(): string { } export function describeError(error: unknown, withStack = false): string { - if (error instanceof Error) return withStack ? (error.stack ?? error.message) : error.message + if (error instanceof AppError) { + const params = error.params && Object.keys(error.params).length > 0 ? ` ${JSON.stringify(error.params)}` : '' + const described = `AppError ${error.code}: ${error.message}${params}` + return withStack ? `${described}\n${error.stack ?? ''}` : described + } + + if (error instanceof Error) { + // the class name tells a network failure from a bug; a plain Error has nothing to add + const described = error.name === 'Error' ? error.message : `${error.name}: ${error.message}` + return withStack ? (error.stack ?? described) : described + } + return String(error) } diff --git a/src/player.ts b/src/player.ts index a933064..89a1557 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,4 +1,4 @@ -import { StateResponse, QueueItem, ActivityReasonCode } from './types.js' +import { StateResponse, QueueItem, ActivityReasonCode, AppError } from './types.js' import { isBlocked, markBlocked } from './blocklist.js' import { logRejection, logFailure } from './activity.js' import { getQueue, getCurrent, getIsPaused, setCurrent, shiftQueue } from './queue.js' @@ -27,7 +27,7 @@ export function moveToNext(): QueueItem | null { while (next && isBlocked(next.videoId)) { log.log(`skipped blocked track in queue: "${next.title}"`) logRejection(next.requestedBy, next.title, 'BLOCKED', { title: next.title, videoId: next.videoId }) - finishItem(next, { code: 'BLOCKED' }) + finishItem(next, new AppError('BLOCKED', next.title)) next = shiftQueue() } @@ -56,7 +56,7 @@ export function skipIfCurrent(videoId: string): boolean { if (current?.videoId !== videoId) return false log.log(`current track was blocked, skipping: "${current.title}"`) - finishItem(current, { code: 'BLOCKED' }) + finishItem(current, new AppError('BLOCKED', current.title)) moveToNext() return true } @@ -103,7 +103,7 @@ export function reportPlaybackFailure(errorCode?: number, videoId?: string): boo log.error(`playback failed: "${failed.title}" (error ${errorCode ?? 'unknown'})`) logFailure(failed, reasonCode, errorCode !== undefined ? { errorCode } : undefined) - finishItem(failed, { code: reasonCode }) + finishItem(failed, new AppError(reasonCode, failed.title)) } moveToNext() diff --git a/src/queue.ts b/src/queue.ts index f4d6ae9..6200a28 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -2,7 +2,7 @@ import { getConfig } from './config.js' import { getVideoById, searchSongs, selectBestSong } from './youtube/index.js' import { parseYouTubeUrl } from './youtube/url.js' import { logRejection, logAcceptance, logSkippedWithOpenReward } from './activity.js' -import { QueueItem, Song, AppError, AddedSong, FailureReason } from './types.js' +import { QueueItem, Song, AppError, AddedSong } from './types.js' import { isBlocked } from './blocklist.js' import { emit } from './state-events.js' import { finishItem } from './finish.js' @@ -178,7 +178,7 @@ export function removeAt(index: number): QueueItem | null { if (item) { log.log(`removed "${item.title}" at position ${index + 1}`) - finishItem(item, { code: 'TRACK_REMOVED' }) + finishItem(item, new AppError('TRACK_REMOVED', item.title)) } emit('state') @@ -192,19 +192,13 @@ export function clearQueue(): QueueItem[] { log.log(`cleared ${cleared.length} songs`) - for (const item of cleared) finishItem(item, { code: 'QUEUE_CLEARED' }) + for (const item of cleared) finishItem(item, new AppError('QUEUE_CLEARED', item.title)) emit('state') return cleared } -export type RequestSongResult = - | { outcome: 'invalid-url' } - | { outcome: 'not-found' } - | { outcome: 'added'; added: AddedSong } - | { outcome: 'error'; reason: FailureReason } - type RequestOptions = { // an admin request (from this computer): the filters and the limits do not apply bypassFilters?: boolean @@ -212,7 +206,9 @@ type RequestOptions = { channelPointsRedemption?: QueueItem['channelPointsRedemption'] } -export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: RequestOptions = {}): Promise<RequestSongResult> { +// Adds the song the query names to the queue, or throws the AppError that says why not (a duplicate, +// a limit, a filter, a bad URL); the activity journal entry is written on the way out, in one place +export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: RequestOptions = {}): Promise<AddedSong> { let song: Song | null = null try { @@ -222,8 +218,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) if (isYouTube && !videoId) { - logRejection(requestedBy, query, 'INVALID_YOUTUBE_URL') - return { outcome: 'invalid-url' } + throw new AppError('INVALID_YOUTUBE_URL', 'the query is a YouTube link without a video id') } if (videoId) { @@ -238,26 +233,23 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi ) } - if (!song) { - logRejection(requestedBy, query, 'SONG_NOT_FOUND') - return { outcome: 'not-found' } - } + if (!song) throw new AppError('SONG_NOT_FOUND', 'no matching track was found') const { item, started } = addSong(song, requestedBy, { bypassLimits: bypassFilters, channelPointsRedemption }) log.log(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) logAcceptance(requestedBy, query, song.title, song.videoId) - const position = started ? 0 : queue.length - - return { outcome: 'added', added: { song: item, started, position } } + return { song: item, started, position: started ? 0 : queue.length } } catch (error) { // an expected refusal (duplicate, limit, filter) is routine and logRejection() writes it down; anything else is a bug or an outage and keeps its details if (!(error instanceof AppError)) log.error(`Unexpected error while adding a song for ${requestedBy}: ${describeError(error, true)}`) - const reasonCode = error instanceof AppError ? error.code : 'SERVER_ERROR' - const reasonParams = error instanceof AppError ? error.params : undefined - logRejection(requestedBy, query, reasonCode, { title: song?.title ?? null, videoId: song?.videoId ?? null, reasonParams }) - return { outcome: 'error', reason: { code: reasonCode, params: reasonParams } } + logRejection(requestedBy, query, error instanceof AppError ? error.code : 'SERVER_ERROR', { + title: song?.title ?? null, + videoId: song?.videoId ?? null, + reasonParams: error instanceof AppError ? error.params : undefined + }) + throw error } } diff --git a/src/routes/blocklist.ts b/src/routes/blocklist.ts index 1cea0ec..a3a3510 100644 --- a/src/routes/blocklist.ts +++ b/src/routes/blocklist.ts @@ -1,11 +1,14 @@ import express from 'express' -import { ok, fail } from '../http.js' -import { isValidVideoId } from '../youtube/url.js' +import { AppError } from '../types.js' +import { ok } from '../http.js' +import { checkVideoId, validateVideoIdParam } from '../validate.js' import { getBlockedTracks, blockTrack, unblockTrack } from '../blocklist.js' import { skipIfCurrent } from '../player.js' export const router = express.Router() +router.param('videoId', validateVideoIdParam) + router.get('/', (_req, res) => { ok(res, { entries: getBlockedTracks() }) }) @@ -13,9 +16,7 @@ router.get('/', (_req, res) => { router.post('/', (req, res) => { const { videoId, title } = req.body ?? {} - if (!isValidVideoId(videoId)) { - return fail(res, 'INVALID_VIDEO_ID', 400) - } + checkVideoId(videoId) const entry = blockTrack(videoId, typeof title === 'string' ? title.slice(0, 200) : videoId) const skipped = skipIfCurrent(videoId) @@ -24,6 +25,7 @@ router.post('/', (req, res) => { router.delete('/:videoId', (req, res) => { const removed = unblockTrack(req.params.videoId) - if (!removed) return fail(res, 'NOT_FOUND', 404) + + if (!removed) throw new AppError('NOT_FOUND', 'no blocked track has that id') ok(res, { removed: true }) }) diff --git a/src/routes/fallback.ts b/src/routes/fallback.ts index fa8d172..2aac9bc 100644 --- a/src/routes/fallback.ts +++ b/src/routes/fallback.ts @@ -1,6 +1,7 @@ import express from 'express' -import { StateResponse, QueueItem, FallbackEnqueueResponse } from '../types.js' -import { ok, fail } from '../http.js' +import { StateResponse, QueueItem, FallbackEnqueueResponse, AppError } from '../types.js' +import { ok } from '../http.js' +import { validateVideoIdParam } from '../validate.js' import { refreshFallback, getFallbackState, @@ -12,16 +13,15 @@ import { } from '../fallback.js' import { getState } from '../player.js' -function fallbackTrackOrNotFound(res: express.Response, item: QueueItem | null): item is QueueItem { - if (!item) { - fail(res, 'NOT_FOUND', 404) - return false - } - return true +function fallbackTrackOrNotFound(item: QueueItem | null): QueueItem { + if (!item) throw new AppError('NOT_FOUND', 'no track with that id is in the playlist') + return item } export const router = express.Router() +router.param('videoId', validateVideoIdParam) + router.get('/', (_req, res) => { ok(res, getFallbackState()) }) @@ -43,13 +43,11 @@ router.post('/enabled', (_req, res) => { }) router.post('/play/:videoId', (req, res) => { - const item = playFallbackTrackNow(req.params.videoId) - if (!fallbackTrackOrNotFound(res, item)) return + fallbackTrackOrNotFound(playFallbackTrackNow(req.params.videoId)) ok<StateResponse>(res, getState()) }) router.post('/enqueue/:videoId', (req, res) => { - const item = queueFallbackTrack(req.params.videoId) - if (!fallbackTrackOrNotFound(res, item)) return + const item = fallbackTrackOrNotFound(queueFallbackTrack(req.params.videoId)) ok<FallbackEnqueueResponse>(res, { song: item, state: getState() }, 201) }) diff --git a/src/routes/index.ts b/src/routes/index.ts index e109f5d..c797f18 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -35,5 +35,5 @@ apiRouter.use('/integrations/twitch', twitchRouter) apiRouter.use((req, res) => { log.warn(`Unknown endpoint: ${req.method} ${req.originalUrl}`) - fail(res, 'NOT_FOUND', 404) + fail(res, 'NOT_FOUND') }) diff --git a/src/routes/player.ts b/src/routes/player.ts index 4aa3491..8d34975 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -1,6 +1,6 @@ import express from 'express' -import { PlayerActionResponse } from '../types.js' -import { ok, fail } from '../http.js' +import { PlayerActionResponse, AppError } from '../types.js' +import { ok } from '../http.js' import { isValidVideoId } from '../youtube/url.js' import { t } from '../i18n.js' import { buildSkipMessage } from '../chat-replies.js' @@ -19,7 +19,7 @@ function readVideoId(body: unknown): { videoId?: string; valid: boolean } { router.post('/ended', (req, res) => { const { videoId, valid } = readVideoId(req.body) - if (!valid) return fail(res, 'INVALID_VIDEO_ID', 400) + if (!valid) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') const applied = endCurrent(videoId) ok(res, { ...getState(), ...(applied ? {} : { ignored: true }) }) @@ -46,7 +46,7 @@ router.post('/resume', (_req, res) => { router.post('/report-failure', (req, res) => { const { videoId, valid } = readVideoId(req.body) - if (!valid) return fail(res, 'INVALID_VIDEO_ID', 400) + if (!valid) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') const errorCode = typeof req.body?.errorCode === 'number' ? req.body.errorCode : undefined const applied = reportPlaybackFailure(errorCode, videoId) diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index c576c0c..6c5913f 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -1,18 +1,21 @@ import express from 'express' -import { ConfigResponse } from '../types.js' -import { ok, fail, failFromError } from '../http.js' +import { ConfigResponse, AppError } from '../types.js' +import { ok } from '../http.js' import { getConfig, updateConfig } from '../config.js' -import { parsePlaylistId, isValidPlaylistId } from '../youtube/url.js' +import { parsePlaylistId } from '../youtube/url.js' import { fetchPlaylistMeta } from '../youtube/index.js' import { getPlaylists, upsertPlaylist, removePlaylist, type PlaylistEntry } from '../playlists.js' import { clearFallback, refreshFallbackOrRollback } from '../fallback.js' import { createLogger } from '../logger.js' +import { checkPlaylistId, validatePlaylistIdParam } from '../validate.js' import { emit } from '../state-events.js' const log = createLogger('PLAYLISTS') export const router = express.Router() +router.param('id', validatePlaylistIdParam) + // every playlist says whether it is the active one, so the panel does not have to look it up in the config function playlistEntries(): PlaylistEntry[] { const activeId = getConfig().fallbackPlaylist.playlistId @@ -28,25 +31,22 @@ router.post('/', async (req, res) => { const parsedId = parsePlaylistId(raw) if (!parsedId) { - return fail(res, 'INVALID_PLAYLIST_ID', 400) + throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') } - try { - const meta = await fetchPlaylistMeta(parsedId) - if (!meta) { - return fail(res, 'PLAYLIST_NOT_FOUND', 404) - } - const saved = upsertPlaylist(meta) - ok(res, { playlist: saved }, 201) - } catch (error) { - failFromError(res, error) + const meta = await fetchPlaylistMeta(parsedId) + if (!meta) { + throw new AppError('PLAYLIST_NOT_FOUND', 'no playlist has that id') } + + const saved = upsertPlaylist(meta) + ok(res, { playlist: saved }, 201) }) router.delete('/:id', (req, res) => { const removed = removePlaylist(req.params.id) if (!removed) { - return fail(res, 'PLAYLIST_NOT_FOUND', 404) + throw new AppError('PLAYLIST_NOT_FOUND', 'no saved playlist has that id') } const wasActive = getConfig().fallbackPlaylist.playlistId === req.params.id @@ -62,12 +62,10 @@ router.delete('/:id', (req, res) => { router.post('/:id/activate', async (req, res) => { const { id } = req.params - if (!isValidPlaylistId(id)) { - return fail(res, 'INVALID_PLAYLIST_ID', 400) - } + checkPlaylistId(id) if (!getPlaylists().some((playlist) => playlist.id === id)) { - return fail(res, 'PLAYLIST_NOT_FOUND', 404) + throw new AppError('PLAYLIST_NOT_FOUND', 'no saved playlist has that id') } const previousId = getConfig().fallbackPlaylist.playlistId diff --git a/src/routes/queue.ts b/src/routes/queue.ts index bfac870..5eab1f9 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -1,57 +1,39 @@ import express from 'express' -import { StateResponse, QueueRemoveResponse, QueueRequestResponse } from '../types.js' -import { ok, fail, failFromReason } from '../http.js' -import { isLocalAddress } from '../local-only.js' +import { StateResponse, QueueRemoveResponse, QueueRequestResponse, AppError } from '../types.js' +import { ok } from '../http.js' +import { wantsBypassFilters } from '../local-only.js' +import { checkQuery, validateVideoIdParam } from '../validate.js' import { t } from '../i18n.js' import { requestSong, removeByVideoId, clearQueue } from '../queue.js' -import { isValidVideoId } from '../youtube/url.js' import { getState } from '../player.js' import { getSettings } from '../settings.js' export const router = express.Router() -router.post('/request', async (req, res) => { - const { query, requestedBy, admin } = req.body ?? {} - const bypassFilters = admin === true && isLocalAddress(req.socket.remoteAddress) +router.param('videoId', validateVideoIdParam) - if (typeof query !== 'string' || query.trim().length < 2 || query.trim().length > 200) { - return fail(res, 'INVALID_QUERY', 400) - } +router.post('/request', async (req, res) => { + const { query, requestedBy } = req.body ?? {} + const bypassFilters = wantsBypassFilters(req) + const checkedQuery = checkQuery(query) if (typeof requestedBy !== 'string' || requestedBy.trim().length === 0) { - return fail(res, 'USERNAME_REQUIRED', 400) + throw new AppError('USERNAME_REQUIRED', 'a username is required') } - const result = await requestSong(query.trim(), requestedBy.trim().slice(0, 50), { bypassFilters }) - - switch (result.outcome) { - case 'invalid-url': - return fail(res, 'INVALID_YOUTUBE_URL', 400) - case 'not-found': - return fail(res, 'SONG_NOT_FOUND', 404) - case 'error': - return failFromReason(res, result.reason) - case 'added': { - const { added } = result - const message = added.started - ? t(getSettings().locale, 'toast.nowPlaying', { title: added.song.title }) - : t(getSettings().locale, 'toast.addedToQueue', { title: added.song.title, position: added.position }) - return ok<QueueRequestResponse>(res, { ...added, state: getState(), message }, 201) - } - } + const added = await requestSong(checkedQuery, requestedBy.trim().slice(0, 50), { bypassFilters }) + + const message = added.started + ? t(getSettings().locale, 'toast.nowPlaying', { title: added.song.title }) + : t(getSettings().locale, 'toast.addedToQueue', { title: added.song.title, position: added.position }) + return ok<QueueRequestResponse>(res, { ...added, state: getState(), message }, 201) }) router.delete('/video/:videoId', (req, res) => { - const { videoId } = req.params - - if (!isValidVideoId(videoId)) { - return fail(res, 'INVALID_VIDEO_ID', 400) - } - - const removed = removeByVideoId(videoId) + const removed = removeByVideoId(req.params.videoId) if (!removed) { - return fail(res, 'QUEUE_ITEM_NOT_FOUND', 404) + throw new AppError('QUEUE_ITEM_NOT_FOUND', 'no queued track has that id') } ok<QueueRemoveResponse>(res, { removed, state: getState() }) diff --git a/src/routes/search.ts b/src/routes/search.ts index 171779a..4f8b3fd 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -1,19 +1,16 @@ import express from 'express' import { SearchResponse } from '../types.js' -import { ok, fail } from '../http.js' -import { isLocalAddress } from '../local-only.js' +import { ok } from '../http.js' +import { wantsBypassFilters } from '../local-only.js' +import { checkQuery } from '../validate.js' import { searchSongs } from '../youtube/index.js' import { markBlocked } from '../blocklist.js' export const router = express.Router() router.get('/', async (req, res) => { - const query = typeof req.query.q === 'string' ? req.query.q.trim() : '' - const bypassFilters = req.query.admin === '1' && isLocalAddress(req.socket.remoteAddress) - - if (query.length < 2) { - return fail(res, 'INVALID_QUERY', 400) - } + const query = checkQuery(req.query.q) + const bypassFilters = wantsBypassFilters(req) const songs = await searchSongs(query, bypassFilters) ok<SearchResponse>(res, { results: markBlocked(songs) }) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index ba2a8e4..bdca9ad 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -1,6 +1,6 @@ import express from 'express' -import { ConfigResponse, SettingsResponse } from '../types.js' -import { ok, fail, sendConfigUpdate } from '../http.js' +import { ConfigResponse, SettingsResponse, AppError } from '../types.js' +import { ok, sendConfigUpdate } from '../http.js' import { isLocalAddress, localOnly } from '../local-only.js' import { getConfig, updateConfig } from '../config.js' import { getSettings, updateSettings } from '../settings.js' @@ -29,8 +29,7 @@ router.put('/config', async (req, res) => { if (body.fallbackPlaylist !== undefined) { if (typeof body.fallbackPlaylist !== 'object' || body.fallbackPlaylist === null || Array.isArray(body.fallbackPlaylist)) { - fail(res, 'INVALID_CONFIG', 400, { fields: 'fallbackPlaylist' }) - return + throw new AppError('INVALID_CONFIG', 'the fallbackPlaylist field is not an object', { fields: 'fallbackPlaylist' }) } if (typeof body.fallbackPlaylist.playlistId === 'string') { @@ -39,8 +38,7 @@ router.put('/config', async (req, res) => { if (rawPlaylistId) { const parsedId = parsePlaylistId(rawPlaylistId) if (!parsedId) { - fail(res, 'INVALID_PLAYLIST_ID', 400) - return + throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') } body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: parsedId } } else { diff --git a/src/routes/twitch.ts b/src/routes/twitch.ts index 494c74c..96596a9 100644 --- a/src/routes/twitch.ts +++ b/src/routes/twitch.ts @@ -3,22 +3,15 @@ import { ok, sendConfigUpdate } from '../http.js' import type { TwitchConnectionResponse, TwitchCreateCustomReward, TwitchUpdateCustomReward } from '../integrations/twitch/types.js' import { AppError } from '../types.js' import { parseRewardInput, toRewardDto } from '../integrations/twitch/rewards.js' -import { startDeviceAuthorization, disconnect, refreshConnection, getClient, getTwitchHealth } from '../integrations/twitch/index.js' +import { startDeviceAuthorization, disconnect, refreshConnection, getClient, isTwitchConnected, getTwitchHealth } from '../integrations/twitch/index.js' import { getTwitchConfig, updateTwitchConfig } from '../integrations/twitch/config.js' -import { localOnly } from '../local-only.js' +import { localOnly, isLocalAddress } from '../local-only.js' import { getPublicSecretsView } from '../secrets.js' -import { isLocalAddress } from '../local-only.js' +import type { TwitchClient } from '../integrations/twitch/client.js' -function toUserResponse(user: { displayName: string; login: string }) { - return { - displayName: user.displayName, - login: user.login - } -} - -function requireClient() { +function requireClient(): TwitchClient { const client = getClient() - if (!client || !getPublicSecretsView().twitch.connected) { + if (!client || !isTwitchConnected()) { throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected') } return client @@ -36,12 +29,15 @@ router.get('/config', (_req, res) => { ok(res, getTwitchConfig()) }) -router.put('/config', localOnly, async (req, res) => { +// everything below is for this machine only +router.use(localOnly) + +router.put('/config', async (req, res) => { const { config, rejected } = updateTwitchConfig(req.body) sendConfigUpdate(res, config, rejected) }) -router.post('/connect', localOnly, async (_req, res) => { +router.post('/connect', async (_req, res) => { const device = await startDeviceAuthorization() ok(res, { @@ -51,17 +47,17 @@ router.post('/connect', localOnly, async (_req, res) => { }) }) -router.post('/disconnect', localOnly, async (_req, res) => { +router.post('/disconnect', async (_req, res) => { const openRedemptions = await disconnect() ok(res, { openRedemptions }) }) -router.post('/refresh', localOnly, async (_req, res) => { +router.post('/refresh', async (_req, res) => { const userInfo = await refreshConnection() - ok(res, { user: toUserResponse(userInfo) }) + ok(res, { user: { displayName: userInfo.displayName, login: userInfo.login } }) }) -router.get('/rewards', localOnly, async (_req, res) => { +router.get('/rewards', async (_req, res) => { const client = requireClient() const rewards = await client.getCustomRewards() @@ -71,7 +67,7 @@ router.get('/rewards', localOnly, async (_req, res) => { }) // A reward is saved like a config: the answer is { config: the stored reward, rejected: the names the panel has to mark } -router.post('/rewards', localOnly, async (req, res) => { +router.post('/rewards', async (req, res) => { const client = requireClient() const { payload, rejected } = parseRewardInput(req.body, { requireTitleAndCost: true }) if (rejected.length) return sendConfigUpdate(res, {}, rejected) @@ -80,7 +76,7 @@ router.post('/rewards', localOnly, async (req, res) => { sendConfigUpdate(res, toRewardDto(reward), []) }) -router.patch('/rewards/:id', localOnly, async (req, res) => { +router.patch('/rewards/:id', async (req, res) => { const client = requireClient() const { payload, rejected } = parseRewardInput(req.body, { requireTitleAndCost: false }) if (rejected.length) return sendConfigUpdate(res, {}, rejected) diff --git a/src/sse.ts b/src/sse.ts index a5dbc43..66136bf 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -95,7 +95,7 @@ export function createEventStreams({ maxClients = 20, pingEveryMs = 25_000, coal function handle(req: Request, res: Response): void { if (clients.size >= maxClients) { - fail(res, 'SERVER_ERROR', 503) + fail(res, 'SERVER_ERROR', undefined, 503) return } diff --git a/src/types.ts b/src/types.ts index 579ece6..571fa74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,38 @@ -export type ActivityReasonCode = - | AppErrorCode +// The one set of error codes the app knows: what a route rejects with, what requestSong throws, +// what the activity log and the chat replies name. Chat-only reasons (playback, track lifecycle) never reach HTTP. +export type AppErrorCode = + | FilterFailureReason + | 'DUPLICATE' + | 'BLOCKED' + | 'QUEUE_FULL' + | 'USER_LIMIT' + | 'YOUTUBE_QUOTA' + | 'YOUTUBE_ERROR' + | 'NO_API_KEY' + | 'TWITCH_AUTH_ERROR' + | 'TWITCH_REFRESH_ERROR' + | 'TWITCH_NOT_CONNECTED' + | 'TWITCH_API_ERROR' + | 'TWITCH_NETWORK_ERROR' + | 'TWITCH_UNKNOWN_ERROR' + | 'INVALID_INPUT' + | 'SERVER_ERROR' + | 'INVALID_QUERY' + | 'INVALID_QUERY_RANGE' + | 'INVALID_VIDEO_ID' + | 'USERNAME_REQUIRED' | 'INVALID_YOUTUBE_URL' | 'SONG_NOT_FOUND' - | 'SERVER_ERROR' + | 'QUEUE_ITEM_NOT_FOUND' + | 'NOT_FOUND' + | 'INVALID_PLAYLIST_ID' + | 'PLAYLIST_NOT_FOUND' + | 'INVALID_CONFIG' + | 'LOCAL_ONLY' + | 'FORBIDDEN_ORIGIN' + | 'INVALID_JSON' + | 'PAYLOAD_TOO_LARGE' + | 'INVALID_REQUEST' | 'PLAYBACK_VIDEO_UNAVAILABLE' | 'PLAYBACK_EMBED_DISALLOWED' | 'PLAYBACK_FAILED' @@ -10,6 +40,8 @@ export type ActivityReasonCode = | 'QUEUE_CLEARED' | 'REWARD_NOT_REFUNDED' +export type ActivityReasonCode = AppErrorCode + export type ActivityStatus = 'accepted' | 'rejected' | 'failed' | 'skipped' export type ActivityEntry = { requestedBy: string @@ -74,7 +106,7 @@ export type PlayerState = { isPaused: boolean } -export type ApiError = { success: false; error: string; code: string; params?: Record<string, string | number> } +export type ApiError = { success: false; error: string; code: AppErrorCode; params?: Record<string, string | number> } export type ApiOk<T> = { success: true; data: T } export type FilterFailureReason = @@ -89,44 +121,6 @@ export type FilterFailureReason = | 'DURATION_OUT_OF_RANGE' | 'VIEWS_TOO_LOW' -export type AppErrorCode = - | 'DUPLICATE' - | 'BLOCKED' - | 'QUEUE_FULL' - | 'USER_LIMIT' - | 'YOUTUBE_QUOTA' - | 'YOUTUBE_ERROR' - | 'NO_API_KEY' - | 'TWITCH_AUTH_ERROR' - | 'TWITCH_REFRESH_ERROR' - | 'TWITCH_NOT_CONNECTED' - | 'TWITCH_API_ERROR' - | 'INVALID_INPUT' - | 'TWITCH_NETWORK_ERROR' - | 'TWITCH_UNKNOWN_ERROR' - | FilterFailureReason - -// A reason a song request or an already-queued track's playback failed, with whatever the reason needs -// to become a chat message ({{title}}, quota numbers, etc.) - shared by activity logging and Twitch chat replies -export type FailureReason = { code: ActivityReasonCode; params?: Record<string, string | number> } - -export type ApiErrorCode = - | ActivityReasonCode - | 'INVALID_QUERY' - | 'INVALID_VIDEO_ID' - | 'USERNAME_REQUIRED' - | 'QUEUE_ITEM_NOT_FOUND' - | 'NOT_FOUND' - | 'INVALID_PLAYLIST_ID' - | 'PLAYLIST_NOT_FOUND' - | 'INVALID_CONFIG' - | 'LOCAL_ONLY' - | 'FORBIDDEN_ORIGIN' - | 'INVALID_JSON' - | 'PAYLOAD_TOO_LARGE' - | 'INVALID_REQUEST' - | 'SERVER_ERROR' - export class AppError extends Error { constructor( public code: AppErrorCode, @@ -134,6 +128,7 @@ export class AppError extends Error { public params?: Record<string, string | number> ) { super(message) + this.name = 'AppError' } } diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..53d72d8 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,40 @@ +import type { NextFunction, Request, Response } from 'express' +import { AppError } from './types.js' +import { isValidPlaylistId, isValidVideoId } from './youtube/url.js' + +// The shared request rules, so two routes that take the same kind of input cannot drift apart: +// every route with a `:videoId` param goes through validateVideoIdParam, every query through checkQuery + +export function checkVideoId(value: unknown): void { + if (!isValidVideoId(value)) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') +} + +export function checkPlaylistId(value: unknown): void { + if (!isValidPlaylistId(value)) throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') +} + +export function checkQuery(value: unknown): string { + const query = typeof value === 'string' ? value.trim() : '' + + if (query.length > 200) throw new AppError('INVALID_QUERY_RANGE', 'the query must be between 2 and 200 characters') + if (query.length < 2) throw new AppError('INVALID_QUERY', 'the query must be at least 2 characters') + return query +} + +export function validateVideoIdParam(req: Request, _res: Response, next: NextFunction, value: unknown): void { + try { + checkVideoId(value) + next() + } catch (error) { + next(error) + } +} + +export function validatePlaylistIdParam(req: Request, _res: Response, next: NextFunction, value: unknown): void { + try { + checkPlaylistId(value) + next() + } catch (error) { + next(error) + } +} diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index af84aca..43ddd38 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -13,6 +13,7 @@ cpSync(locales, join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) const { buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage } = await import('../../src/chat-replies.js') +const { AppError } = await import('../../src/types.js') test('buildRedemptionAcceptedMessage', async (t) => { await t.test('names the track and its place in the queue', () => { @@ -33,23 +34,23 @@ test('buildRedemptionAcceptedMessage', async (t) => { test('buildRedemptionRejectionMessage', async (t) => { await t.test('a request that was never queued gets only the reason', () => { - assert.equal(buildRedemptionRejectionMessage('alice', { code: 'DUPLICATE' }), '@alice, that track is already in the queue, points refunded') + assert.equal(buildRedemptionRejectionMessage('alice', new AppError('DUPLICATE', 'already queued')), '@alice, that track is already in the queue, points refunded') }) await t.test('a playback failure names the track and the reason', () => { - const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_VIDEO_UNAVAILABLE', params: { title: 'Song' } }) + const message = buildRedemptionRejectionMessage('alice', new AppError('PLAYBACK_VIDEO_UNAVAILABLE', 'playback failed', { title: 'Song' })) assert.equal(message, '@alice, “Song” could not be played: the video became unavailable, points refunded') }) await t.test('every playback failure reason names the track', () => { for (const code of ['PLAYBACK_VIDEO_UNAVAILABLE', 'PLAYBACK_EMBED_DISALLOWED', 'PLAYBACK_FAILED'] as const) { - const message = buildRedemptionRejectionMessage('alice', { code, params: { title: 'Song' } }) + const message = buildRedemptionRejectionMessage('alice', new AppError(code, 'playback failed', { title: 'Song' })) assert.ok(message.includes('“Song”') && !message.includes('{{'), code) } }) await t.test('a long title in a playback failure is shortened', () => { - const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_FAILED', params: { title: 'A'.repeat(100) } }) + const message = buildRedemptionRejectionMessage('alice', new AppError('PLAYBACK_FAILED', 'playback failed', { title: 'A'.repeat(100) })) assert.ok(message.includes(`“${'A'.repeat(39)}…”`)) }) }) diff --git a/tests/backend/error-handler.test.ts b/tests/backend/error-handler.test.ts index dbc8e85..870591e 100644 --- a/tests/backend/error-handler.test.ts +++ b/tests/backend/error-handler.test.ts @@ -3,6 +3,8 @@ import assert from 'node:assert/strict' import type { AddressInfo } from 'node:net' import express from 'express' import { errorHandler } from '../../src/error-handler.js' +import { AppError } from '../../src/types.js' +import { describeError } from '../../src/logger.js' const app = express() app.use(express.json({ limit: '1kb' })) @@ -15,6 +17,12 @@ app.get('/late', (_req, res, next) => { res.write('partial') next(new Error('after the headers were sent')) }) +app.get('/refused', () => { + throw new AppError('DUPLICATE', 'this track is already in the queue') +}) +app.get('/twitch', () => { + throw new AppError('TWITCH_API_ERROR', 'title must be unique', { status: 400 }) +}) app.use(errorHandler) const server = app.listen(0) @@ -64,6 +72,32 @@ test('errorHandler', async (t) => { assert.equal(text.includes('secret internal detail'), false) }) + await t.test('a thrown AppError answers with its own code, status and parameters', async () => { + const response = await fetch(`${base}/refused`) + const body = (await response.json()) as Record<string, any> + + assert.equal(response.status, 409) + assert.equal(body.success, false) + assert.equal(body.code, 'DUPLICATE') + assert.equal(body.params.message, 'this track is already in the queue') + }) + + await t.test('a thrown Twitch API error carries the message Twitch returned', async () => { + const response = await fetch(`${base}/twitch`) + const body = (await response.json()) as Record<string, any> + + assert.equal(response.status, 502) + assert.equal(body.code, 'TWITCH_API_ERROR') + assert.ok(body.error.includes('title must be unique')) + }) + + await t.test('describeError says which error it was, not only its message', () => { + assert.equal(describeError(new AppError('USER_LIMIT', 'you can only queue 2 track(s) at a time', { count: 2 })), 'AppError USER_LIMIT: you can only queue 2 track(s) at a time {"count":2}') + assert.equal(describeError(new TypeError('fetch failed')), 'TypeError: fetch failed') + assert.equal(describeError(new Error('a plain one')), 'a plain one') + assert.equal(describeError('not even an error'), 'not even an error') + }) + await t.test('an error after the response has started is left to Express instead of writing twice', async (t) => { t.mock.method(console, 'error', () => {}) const response = await fetch(`${base}/late`).catch(() => null) diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index 2da4f53..edd5a72 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -1,20 +1,24 @@ import { test, beforeEach, after } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' +import { cpSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' -process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) +const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) +cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) +process.chdir(workDir) const { isRetryable, _test, _resetIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') const { buildRedemptionRejectionMessage, buildRedemptionRefundFailedMessage } = await import('../../../../src/chat-replies.js') const { AppError } = await import('../../../../src/types.js') +const { blockTrack, unblockTrack } = await import('../../../../src/blocklist.js') +const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const queue = await import('../../../../src/queue.js') import type { TwitchChat } from '../../../../src/integrations/twitch/chat.js' import type { TwitchClient } from '../../../../src/integrations/twitch/client.js' const redemption = { id: 'red-1', rewardId: 'reward-1', userName: 'viewer' } -const reason = { code: 'PLAYBACK_FAILED' } as const +const reason = new AppError('PLAYBACK_FAILED', 'playback failed') const refunded = () => buildRedemptionRejectionMessage('viewer', reason) const refundFailed = () => buildRedemptionRefundFailedMessage('viewer') @@ -152,3 +156,49 @@ test('disconnect()', async (t) => { assert.equal(await disconnect(), 0, 'a second disconnect finds nothing to leave') }) }) + +test('handleChannelPointsRedemption()', async (t) => { + const event = (id: string, userInput: string) => ({ + id, + user_name: 'viewer', + user_input: userInput, + reward: { id: 'reward-1', title: 'Song request' } + }) + + t.beforeEach(() => { + initializeTwitchIntegration({ clientId: 'client-1' }) + updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) + _test.setRetryDelay(1) + calls = [] + }) + + t.after(() => { + _test.setChat(null) + }) + + await t.test('a redemption of another reward is ignored at debug level and not touched', async (t2) => { + const debug = t2.mock.method(console, 'log', () => {}) + const errors = t2.mock.method(console, 'error', () => {}) + + await _test.handleChannelPointsRedemption({ ...event('red-other', 'song'), reward: { id: 'another-reward', title: 'Other' } } as never, fakeClient('ok')) + + assert.deepEqual(calls, [], 'no status update for a foreign reward') + assert.equal(debug.mock.callCount(), 0, 'a debug line is below the default info level and does not show') + assert.equal(errors.mock.callCount(), 0) + }) + + await t.test('an ordinary refusal (a blocked track) is a warning, not an error, and refunds the points', async (t2) => { + const warnings = t2.mock.method(console, 'warn', () => {}) + const errors = t2.mock.method(console, 'error', () => {}) + const A = 'aaaaaaaaaaa' + blockTrack(A, 'blocked') + + await _test.handleChannelPointsRedemption(event('red-block', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')) + + assert.deepEqual(calls, ['CANCELED']) + assert.equal(warnings.mock.callCount(), 1) + assert.equal(errors.mock.callCount(), 0, 'a viewer asking for a blocked track is not an application error') + + unblockTrack(A) + }) +}) diff --git a/tests/backend/routes/blocklist.test.ts b/tests/backend/routes/blocklist.test.ts new file mode 100644 index 0000000..960e7a5 --- /dev/null +++ b/tests/backend/routes/blocklist.test.ts @@ -0,0 +1,37 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AddressInfo } from 'node:net' + +process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) + +const express = (await import('express')).default +const { router } = await import('../../../src/routes/blocklist.js') +const { errorHandler } = await import('../../../src/error-handler.js') + +const app = express() +app.use(express.json()) +app.use('/api/blocklist', router) +app.use(errorHandler) + +const server = app.listen(0) +const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/blocklist` +after(() => server.close()) + +test('DELETE /api/blocklist/:videoId', async (t) => { + await t.test('a malformed id is rejected by the shared param check, like the POST body is', async () => { + const response = await fetch(`${base}/oops`, { method: 'DELETE' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_VIDEO_ID') + }) + + await t.test('a well-formed id that is not blocked is a 404', async () => { + const response = await fetch(`${base}/aaaaaaaaaaa`, { method: 'DELETE' }) + + assert.equal(response.status, 404) + assert.equal(((await response.json()) as Record<string, any>).code, 'NOT_FOUND') + }) +}) diff --git a/tests/backend/routes/player.test.ts b/tests/backend/routes/player.test.ts index df7ef15..602f5f8 100644 --- a/tests/backend/routes/player.test.ts +++ b/tests/backend/routes/player.test.ts @@ -9,6 +9,7 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const express = (await import('express')).default const { router } = await import('../../../src/routes/player.js') +const { errorHandler } = await import('../../../src/error-handler.js') const queue = await import('../../../src/queue.js') const player = await import('../../../src/player.js') const { getActivity, clearActivity } = await import('../../../src/activity.js') @@ -16,6 +17,8 @@ const { getActivity, clearActivity } = await import('../../../src/activity.js') const app = express() app.use(express.json()) app.use('/api/player', router) +// a malformed videoId is thrown, not answered inline +app.use(errorHandler) const server = app.listen(0) const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/player` diff --git a/tests/backend/routes/playlists.test.ts b/tests/backend/routes/playlists.test.ts index e8d5b74..1d0cda8 100644 --- a/tests/backend/routes/playlists.test.ts +++ b/tests/backend/routes/playlists.test.ts @@ -75,6 +75,13 @@ test('GET /api/playlists', async (t) => { }) test('DELETE /api/playlists/:id', async (t) => { + await t.test('a malformed id is rejected by the shared param check before the route runs', async () => { + const response = await fetch(`${base}/oops`, { method: 'DELETE' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_PLAYLIST_ID') + }) + await t.test('answers with the playlists that are left, with their active marks, and says when it cleared the active one', async () => { updateConfig({ fallbackPlaylist: { playlistId: 'PLsecond00000001' } }) diff --git a/tests/backend/routes/queue.security.test.ts b/tests/backend/routes/queue.security.test.ts index 97a27b0..8518280 100644 --- a/tests/backend/routes/queue.security.test.ts +++ b/tests/backend/routes/queue.security.test.ts @@ -10,6 +10,7 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const express = (await import('express')).default const { router } = await import('../../../src/routes/queue.js') const { router: searchRouter } = await import('../../../src/routes/search.js') +const { errorHandler } = await import('../../../src/error-handler.js') const { updateConfig } = await import('../../../src/config.js') const { updateSecrets } = await import('../../../src/secrets.js') const queue = await import('../../../src/queue.js') @@ -46,6 +47,7 @@ function appWithRemoteAddress(remoteAddress: string) { }) app.use('/api/queue', router) app.use('/api/search', searchRouter) + app.use(errorHandler) return app } @@ -108,4 +110,13 @@ test('"admin" filter bypass', async (t) => { assert.equal(body.data.results.length, 1) }) }) + + await t.test('GET /api/search: the same 2-200 character limit as a queue request', async () => { + await withServer('127.0.0.1', async (base) => { + const response = await fetch(`${base}/search?q=${'x'.repeat(201)}`) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_QUERY_RANGE') + }) + }) }) diff --git a/tests/backend/routes/queue.test.ts b/tests/backend/routes/queue.test.ts index 08cd28a..15126c9 100644 --- a/tests/backend/routes/queue.test.ts +++ b/tests/backend/routes/queue.test.ts @@ -13,6 +13,7 @@ process.chdir(workDir) const express = (await import('express')).default const { router } = await import('../../../src/routes/queue.js') +const { errorHandler } = await import('../../../src/error-handler.js') const { updateSecrets } = await import('../../../src/secrets.js') const queue = await import('../../../src/queue.js') const player = await import('../../../src/player.js') @@ -39,6 +40,8 @@ globalThis.fetch = async (input, init) => { const app = express() app.use(express.json()) app.use('/api/queue', router) +// the routes throw their refusals, this is where they become API answers +app.use(errorHandler) app.get('/api/state', (_req, res) => res.json({ success: true, ...player.getState() })) const server = app.listen(0) @@ -113,4 +116,28 @@ test('POST /api/queue/request', async (t) => { assert.equal(rejected?.reasonCode, 'DUPLICATE') assert.equal(rejected?.requestedBy, 'amy') }) + + await t.test('a query over 200 characters is a 400 INVALID_QUERY_RANGE', async () => { + const response = await request({ query: 'x'.repeat(201), requestedBy: 'amy' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_QUERY_RANGE') + }) + + await t.test('a YouTube link without a video id is a 400 INVALID_YOUTUBE_URL', async () => { + const response = await request({ query: 'https://www.youtube.com/nothing-here', requestedBy: 'amy' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_YOUTUBE_URL') + assert.equal(getActivity().find((entry) => entry.status === 'rejected')?.reasonCode, 'INVALID_YOUTUBE_URL') + }) +}) + +test('DELETE /api/queue/video/:videoId', async (t) => { + await t.test('a malformed id is rejected by the shared param check', async () => { + const response = await fetch(`${base}/queue/video/oops` , { method: 'DELETE' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_VIDEO_ID') + }) }) From 414e1bda64b8a3ce064b7257594322106c3c2bdc Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:24:38 +0700 Subject: [PATCH 07/22] Harden persistence, Twitch auth, and YouTube URL handling Persistence: - fix fallback state reconciliation - preserve malformed persisted data - make save retry policies configurable per store - log server-side application errors Twitch: - improve error classification - prevent late token refresh responses from overwriting disconnected account state YouTube: - fix search query normalization and playlist URL handling --- src/error-handler.ts | 7 +- src/fallback.ts | 19 +++- src/http.ts | 8 +- src/integrations/twitch/error.ts | 7 +- src/integrations/twitch/index.ts | 7 +- src/integrations/twitch/oauth.ts | 22 +++- src/persist.ts | 26 ++--- src/types.ts | 5 + src/youtube/index.ts | 4 +- src/youtube/url.ts | 17 ++- tests/backend/error-handler.test.ts | 25 ++++ tests/backend/fallback.restart.test.ts | 40 ++++++- .../backend/integrations/twitch/error.test.ts | 41 +++++++ .../backend/integrations/twitch/oauth.test.ts | 42 +++++++ tests/backend/persist.test.ts | 107 +++++++++--------- tests/backend/youtube/quota.test.ts | 25 ++++ tests/backend/youtube/url.test.ts | 11 ++ 17 files changed, 322 insertions(+), 91 deletions(-) create mode 100644 tests/backend/integrations/twitch/error.test.ts diff --git a/src/error-handler.ts b/src/error-handler.ts index acfb4b8..79d07db 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -1,5 +1,5 @@ import type { NextFunction, Request, Response } from 'express' -import { fail, failFromAppError } from './http.js' +import { fail, failFromAppError, statusForCode } from './http.js' import { AppError } from './types.js' import { createLogger, describeError } from './logger.js' @@ -15,7 +15,10 @@ export function errorHandler(err: HttpError, _req: Request, res: Response, next: return } - if (err instanceof AppError) return failFromAppError(res, err) + if (err instanceof AppError) { + if (statusForCode(err.code) >= 500) log.error(describeError(err)) + return failFromAppError(res, err) + } if (err.type === 'entity.parse.failed') return fail(res, 'INVALID_JSON') if (err.type === 'entity.too.large') return fail(res, 'PAYLOAD_TOO_LARGE') diff --git a/src/fallback.ts b/src/fallback.ts index b8f11c5..100005c 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -258,9 +258,22 @@ export function alignFallbackCursor(videoId: string): void { export function hydrateFallback(data: Partial<FallbackSnapshot> | undefined): void { if (!data) return - setSourceTracks(data.sourceTracks ?? []) - fallbackOrder = data.order ?? [] - fallbackCursor = data.cursor ?? -1 + const tracks = data.sourceTracks ?? [] + setSourceTracks(tracks) + + const savedOrder = data.order ?? [] + const known = new Set(tracks.map((track) => track.videoId)) + const kept = [...new Set(savedOrder.filter((id) => known.has(id)))] + const keptIds = new Set(kept) + const missing: string[] = [] + for (const id of known) { + if (!keptIds.has(id)) missing.push(id) + } + const order = [...kept, ...missing] + const repaired = savedOrder.length !== order.length || order.some((id, index) => id !== savedOrder[index]) + + fallbackOrder = order + fallbackCursor = repaired ? -1 : data.cursor ?? -1 loadedFallbackPlaylistId = data.playlistId ?? null lastFallbackRefreshAt = data.lastRefreshedAt ?? null } diff --git a/src/http.ts b/src/http.ts index 7c2dda1..af5fc91 100644 --- a/src/http.ts +++ b/src/http.ts @@ -65,13 +65,17 @@ const STATUS_BY_CODE: Partial<Record<AppErrorCode, number>> = { ...filterStatuses(['NOT_MUSIC', 'NOT_PUBLIC', 'NOT_EMBEDDABLE', 'AGE_RESTRICTED', 'REGION_BLOCKED', 'NOT_PLAYABLE', 'IS_LIVE', 'IS_SHORT', 'DURATION_OUT_OF_RANGE', 'VIEWS_TOO_LOW'], 404) } +export function statusForCode(code: AppErrorCode): number { + return STATUS_BY_CODE[code] ?? 500 +} + function filterStatuses(codes: readonly FilterFailureReason[], status: number): Partial<Record<AppErrorCode, number>> { return Object.fromEntries(codes.map((code) => [code, status])) } // The one way an error leaves the app: the errorHandler passes the thrown AppError here, the few // middleware that answer themselves (localOnly, hostCheck, the SSE connection limit) call it directly -export function fail(res: Response, code: AppErrorCode, params?: Record<string, string | number>, status = STATUS_BY_CODE[code] ?? 500): void { +export function fail(res: Response, code: AppErrorCode, params?: Record<string, string | number>, status = statusForCode(code)): void { const locale = getSettings().locale const body: ApiError = { success: false, error: translateApiError(locale, code, params), code, params } res.status(status).json(body) @@ -79,5 +83,5 @@ export function fail(res: Response, code: AppErrorCode, params?: Record<string, // AppError's message reaches the panel as the {{message}} parameter (TWITCH_API_ERROR carries Twitch's own text) export function failFromAppError(res: Response, error: AppError): void { - fail(res, error.code, { ...error.params, message: error.message }) + fail(res, error.code, error.toParams()) } diff --git a/src/integrations/twitch/error.ts b/src/integrations/twitch/error.ts index ff902a2..939296a 100644 --- a/src/integrations/twitch/error.ts +++ b/src/integrations/twitch/error.ts @@ -10,9 +10,12 @@ export function markTransient(error: AppError): AppError { // fetch fails as a TypeError ("fetch failed", "terminated"), and a timeout as a DOMException named // TimeoutError or AbortError: those are "no connection to Twitch". Any other Error is something else (a -// refusal, a bug of our own) and must not be reported as a missing connection +// refusal, a bug of our own — a TypeError with another message included) and must not be reported as a +// missing connection +const NETWORK_TYPE_ERRORS = new Set(['fetch failed', 'terminated']) + export function isNetworkError(error: unknown): boolean { - if (error instanceof TypeError) return true + if (error instanceof TypeError) return NETWORK_TYPE_ERRORS.has(error.message) return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError') } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 0b7bdbe..7726694 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -458,11 +458,14 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon } log.error(`Twitch authorization failed: ${describeError(error)}`) + + // a non-AppError is a bug: it stays in the log above, and the panel gets the generic auth failure + const failure = error instanceof AppError ? error : undefined notify({ type: 'error', level: 'error', - code: error instanceof AppError ? error.code : 'TWITCH_AUTH_ERROR', - params: error instanceof AppError ? { ...error.params, message: error.message } : undefined + code: failure?.code ?? 'TWITCH_AUTH_ERROR', + params: failure?.toParams() }) }) .finally(() => { diff --git a/src/integrations/twitch/oauth.ts b/src/integrations/twitch/oauth.ts index 251686d..398baf0 100644 --- a/src/integrations/twitch/oauth.ts +++ b/src/integrations/twitch/oauth.ts @@ -228,6 +228,10 @@ export class TwitchOAuth { this.onAuthStateChange?.() } + private isCurrent(generation: number): boolean { + return generation === this.tokenGeneration + } + needsReauthorization(): boolean { return this.authFailed } @@ -269,12 +273,23 @@ export class TwitchOAuth { throw await oauthError(response, 'Token refresh failed') } - const tokenData = this.store((await response.json()) as TwitchTokenResponse, expectedGeneration) + const data = (await response.json()) as TwitchTokenResponse + + // the account can be disconnected while the request is on its way: the tokens Twitch issued are + // revoked instead of replacing whoever connected in the meantime (the same thing pollForToken does) + if (!this.isCurrent(expectedGeneration)) { + await this.revoke([data.access_token, data.refresh_token]) + throw new AppError('TWITCH_REFRESH_ERROR', 'Login was cancelled during token refresh') + } + + const tokenData = this.store(data, expectedGeneration) log.log('Token refresh successful') return tokenData } catch (error) { if (error instanceof AppError) { - log.error(describeError(error)) + // a refresh dropped because the account was disconnected meanwhile is an orderly exit, not a failure + if (this.isCurrent(expectedGeneration)) log.error(describeError(error)) + else log.log('Token refresh dropped: the account was disconnected meanwhile') throw error } // Network errors become TWITCH_REFRESH_ERROR @@ -284,7 +299,8 @@ export class TwitchOAuth { } private store(data: TwitchTokenResponse, expectedGeneration: number): TwitchTokenData { - if (expectedGeneration !== this.tokenGeneration) { + // a second guard: the refresh answers one at a time, but a device login may have won the race + if (!this.isCurrent(expectedGeneration)) { throw new AppError('TWITCH_REFRESH_ERROR', 'Login was cancelled during token refresh') } diff --git a/src/persist.ts b/src/persist.ts index 225d90c..5165e58 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -7,12 +7,10 @@ import { createLogger, describeError } from './logger.js' const SAVE_DEBOUNCE_MS = 250 const SAVE_ATTEMPTS = 3 -let saveRetryDelayMs = 150 +const SAVE_RETRY_DELAY_MS = 150 const RETRYABLE_SAVE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']) -let renameFile: typeof rename = rename - const log = createLogger('PERSIST') // Paths are resolved when they are used, never at import time, so nothing depends on the cwd of the first import @@ -56,8 +54,12 @@ function setAside(filePath: string): void { const activeStoreFlushers: Array<() => Promise<void>> = [] -export function createFileStore<T>(location: string | (() => string)) { +// `options` exists for the tests: a rename that keeps failing and a short retry delay are injected per +// store, so no test reaches into the module's own state +export function createFileStore<T>(location: string | (() => string), options: { rename?: typeof rename; retryDelayMs?: number } = {}) { const resolvePath = typeof location === 'function' ? location : () => location + const renameFile = options.rename ?? rename + const retryDelayMs = options.retryDelayMs ?? SAVE_RETRY_DELAY_MS type SaveJob = { getData: () => T; onError?: (error: unknown) => void } @@ -75,6 +77,8 @@ export function createFileStore<T>(location: string | (() => string)) { const data: unknown = JSON.parse(readFileSync(filePath, 'utf8')) if (kindOf(data) !== kindOf(defaults)) { log.warn(`${filePath} holds a ${kindOf(data)} where a ${kindOf(defaults)} belongs, using the defaults`) + // kept aside like a corrupt file, so the next save cannot silently erase the only copy + setAside(filePath) return defaults } return deepMerge(defaults, data as DeepPartial<T>) @@ -106,7 +110,7 @@ export function createFileStore<T>(location: string | (() => string)) { await rm(tmpPath, { force: true }).catch(() => {}) throw error } - await sleep(saveRetryDelayMs) + await sleep(retryDelayMs) } } } @@ -177,18 +181,6 @@ export async function ensureDataDirectory(): Promise<void> { } } -export const _test = { - setRename(next: typeof rename): void { - renameFile = next - }, - resetRename(): void { - renameFile = rename - }, - setSaveRetryDelay(ms: number): void { - saveRetryDelayMs = ms - } -} - /** * Reads a file that holds a list. Valid JSON is not always what the code expects (a file edited by hand, one left by an * older version): what is not a list becomes an empty one, and an item that fails `isItem` is dropped diff --git a/src/types.ts b/src/types.ts index 571fa74..5bdf9bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,11 @@ export class AppError extends Error { super(message) this.name = 'AppError' } + + // the one way the error's own text travels: as the {{message}} parameter next to its params + toParams(): Record<string, string | number> { + return { ...this.params, message: this.message } + } } export type Blockable<T> = T & { isBlocked: boolean } diff --git a/src/youtube/index.ts b/src/youtube/index.ts index b7892f5..41f09b2 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -72,7 +72,9 @@ async function fetchVideoById(videoId: string): Promise<VideoItem | null> { } export async function searchSongs(query: string, bypassFilters = false): Promise<Song[]> { - const normalizedQuery = normalize(query) + // normalize() keeps letters and digits only, so a group named "!!!" would search nothing: when + // normalization empties the query, the trimmed lowercase text is the key, and the API still gets it as typed + const normalizedQuery = normalize(query) || query.trim().toLowerCase() if (!normalizedQuery) return [] const cacheKey = searchCacheKey(normalizedQuery) diff --git a/src/youtube/url.ts b/src/youtube/url.ts index d647dab..402f02b 100644 --- a/src/youtube/url.ts +++ b/src/youtube/url.ts @@ -25,23 +25,28 @@ function toUrl(input: string): URL | null { } } -export function parseYouTubeUrl(input: string): { isYouTube: boolean; videoId: string | null } { +const hostOf = (url: URL): string => url.hostname.replace(/^(?:www\.|m\.)+/, '') + +/** A URL on a YouTube host, or null: a list param on any other host belongs to that host, not to us. */ +function youtubeUrl(input: string): URL | null { const url = toUrl(input) - if (!url) return { isYouTube: false, videoId: null } + return url && HOSTS.has(hostOf(url)) ? url : null +} - const host = url.hostname.replace(/^(?:www\.|m\.)+/, '') - if (!HOSTS.has(host)) return { isYouTube: false, videoId: null } +export function parseYouTubeUrl(input: string): { isYouTube: boolean; videoId: string | null } { + const url = youtubeUrl(input) + if (!url) return { isYouTube: false, videoId: null } // youtu.be/<id> · /watch?v=<id> · /shorts|embed|v|live/<id> — segment must be the whole id const [kind, segment] = url.pathname.split('/').filter(Boolean) - const id = host === 'youtu.be' ? kind : kind === 'watch' ? url.searchParams.get('v') : VIDEO_KINDS.has(kind) ? segment : null + const id = hostOf(url) === 'youtu.be' ? kind : kind === 'watch' ? url.searchParams.get('v') : VIDEO_KINDS.has(kind) ? segment : null return { isYouTube: true, videoId: isValidVideoId(id) ? id : null } } export function parsePlaylistId(input: string): string | null { const s = input.trim() - const list = toUrl(s)?.searchParams.get('list') + const list = youtubeUrl(s)?.searchParams.get('list') // if s was a URL, it can't match PLAYLIST_ID, so the fallback only fires for bare IDs return isValidPlaylistId(list) ? list : isValidPlaylistId(s) ? s : null } diff --git a/tests/backend/error-handler.test.ts b/tests/backend/error-handler.test.ts index 870591e..4752c64 100644 --- a/tests/backend/error-handler.test.ts +++ b/tests/backend/error-handler.test.ts @@ -23,6 +23,9 @@ app.get('/refused', () => { app.get('/twitch', () => { throw new AppError('TWITCH_API_ERROR', 'title must be unique', { status: 400 }) }) +app.get('/twitch-network', () => { + throw new AppError('TWITCH_NETWORK_ERROR', 'connect ECONNREFUSED') +}) app.use(errorHandler) const server = app.listen(0) @@ -91,6 +94,28 @@ test('errorHandler', async (t) => { assert.ok(body.error.includes('title must be unique')) }) + await t.test('a server-side AppError is logged, an expected refusal is not', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + + const response = await fetch(`${base}/twitch-network`) + assert.equal(response.status, 502) + assert.equal(errors.mock.callCount(), 1) + const reported = String(errors.mock.calls[0]?.arguments ?? '') + assert.ok(reported.includes('TWITCH_NETWORK_ERROR'), reported) + assert.ok(reported.includes('connect ECONNREFUSED'), reported) + + await fetch(`${base}/refused`) // DUPLICATE, 409 + assert.equal(errors.mock.callCount(), 1, 'a 4xx refusal leaves no error line') + }) + + await t.test('AppError.toParams() is the one shape the message travels in', () => { + assert.deepEqual(new AppError('TWITCH_API_ERROR', 'title must be unique', { status: 400 }).toParams(), { + status: 400, + message: 'title must be unique' + }) + assert.deepEqual(new AppError('DUPLICATE', 'already queued').toParams(), { message: 'already queued' }) + }) + await t.test('describeError says which error it was, not only its message', () => { assert.equal(describeError(new AppError('USER_LIMIT', 'you can only queue 2 track(s) at a time', { count: 2 })), 'AppError USER_LIMIT: you can only queue 2 track(s) at a time {"count":2}') assert.equal(describeError(new TypeError('fetch failed')), 'TypeError: fetch failed') diff --git a/tests/backend/fallback.restart.test.ts b/tests/backend/fallback.restart.test.ts index 6e33470..e03c172 100644 --- a/tests/backend/fallback.restart.test.ts +++ b/tests/backend/fallback.restart.test.ts @@ -126,16 +126,52 @@ test('after a restart the playlist goes on from where it was', async (t) => { }) }) +test('the rotation is reconciled with the track list it is saved next to', async (t) => { + // the order lives in data/, the tracks in cache/: they can drift apart when the cache is wiped + const trackIds = ['trk00000000', 'trk00000001', 'trk00000002'] + const tracks = trackIds.map(song) + + await t.test('a rotation with a vanished id and a duplicate becomes exactly the track list, from the start', () => { + fallback.hydrateFallback({ + sourceTracks: tracks, + order: [trackIds[0], 'trkgone00001', trackIds[1], trackIds[0]], + cursor: 2, + playlistId: PLAYLIST, + lastRefreshedAt: 1 + }) + + assert.deepEqual(fallback.getFallbackProgress().order, trackIds) + assert.equal(fallback.getFallbackProgress().cursor, -1) + }) + + await t.test('a track missing from the rotation is appended, not lost: it plays again', () => { + fallback.hydrateFallback({ sourceTracks: tracks, order: [trackIds[2], trackIds[1]], cursor: 1, playlistId: PLAYLIST, lastRefreshedAt: 1 }) + + assert.deepEqual(fallback.getFallbackProgress().order, [trackIds[2], trackIds[1], trackIds[0]]) + assert.equal(fallback.getFallbackProgress().cursor, -1) + }) + + await t.test('a rotation that already matches the track list is left alone, cursor included', () => { + fallback.hydrateFallback({ sourceTracks: tracks, order: [...trackIds], cursor: 2, playlistId: PLAYLIST, lastRefreshedAt: 1 }) + + assert.deepEqual(fallback.getFallbackProgress().order, trackIds) + assert.equal(fallback.getFallbackProgress().cursor, 2) + }) +}) + test('without any track the position is not used up', async (t) => { // without repeat, walking an empty rotation to its end would leave the playlist "finished": that is what must not happen t.beforeEach(() => updateConfig({ fallbackPlaylist: { repeat: false } })) - await t.test('nothing to play: the position stays and the answer is null', () => { + await t.test('nothing to play: the answer is null and the position is not used up', () => { fallback.hydrateFallback({ sourceTracks: [], order: ids, cursor: 9, playlistId: PLAYLIST, lastRefreshedAt: 1 }) + // the rotation without its tracks is repaired to an empty one, so the position starts over + assert.deepEqual(fallback.getFallbackProgress().order, []) + assert.equal(fallback.getFallbackProgress().cursor, -1) assert.equal(fallback.advanceFallback(), null) assert.equal(fallback.peekNextFallbackTrack(), null) - assert.equal(fallback.getFallbackProgress().cursor, 9) + assert.equal(fallback.getFallbackProgress().cursor, -1, 'advancing an empty rotation consumes nothing') }) await t.test('and once the tracks are there the rotation goes on from the same place', () => { diff --git a/tests/backend/integrations/twitch/error.test.ts b/tests/backend/integrations/twitch/error.test.ts new file mode 100644 index 0000000..419201d --- /dev/null +++ b/tests/backend/integrations/twitch/error.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { isNetworkError, normalizeTwitchError } from '../../../../src/integrations/twitch/error.js' + +const named = (name: string): Error => { + const error = new Error('x') + error.name = name + return error +} + +test('isNetworkError()', async (t) => { + await t.test("undici's own wording for a lost connection counts", () => { + assert.equal(isNetworkError(new TypeError('fetch failed')), true) + assert.equal(isNetworkError(new TypeError('terminated')), true) + }) + + await t.test('a timeout or an abort is a lost connection too', () => { + assert.equal(isNetworkError(named('TimeoutError')), true) + assert.equal(isNetworkError(named('AbortError')), true) + }) + + await t.test('a TypeError of our own is a bug, not a missing connection', () => { + assert.equal(isNetworkError(new TypeError('x is not a function')), false) + }) +}) + +test('normalizeTwitchError()', async (t) => { + await t.test('a lost connection becomes a transient network error', () => { + const normalized = normalizeTwitchError(new TypeError('fetch failed')) + + assert.equal(normalized.code, 'TWITCH_NETWORK_ERROR') + assert.equal(normalized.params?.transient, 1) + }) + + await t.test('a bug of our own stays unknown instead of wearing the network label', () => { + const normalized = normalizeTwitchError(new TypeError('x is not a function')) + + assert.equal(normalized.code, 'TWITCH_UNKNOWN_ERROR') + assert.equal(normalized.params?.transient, undefined) + }) +}) diff --git a/tests/backend/integrations/twitch/oauth.test.ts b/tests/backend/integrations/twitch/oauth.test.ts index 74576bb..e36ee9d 100644 --- a/tests/backend/integrations/twitch/oauth.test.ts +++ b/tests/backend/integrations/twitch/oauth.test.ts @@ -102,3 +102,45 @@ test('revokeTokens()', async (t) => { assert.equal(sent.length, 0) }) }) + +test('a refresh answer that lands after the account was disconnected', async (t) => { + await t.test('the tokens Twitch issued are revoked, nothing is stored, and the drop is not an error', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + const logs = t2.mock.method(console, 'log', () => {}) + let updated = 0 + + let answerToken: ((response: Response) => void) | null = null + sent = [] + globalThis.fetch = async (input, init) => { + const url = String(input instanceof Request ? input.url : input) + sent.push({ url, body: new URLSearchParams(String(init?.body ?? '')) }) + + if (url === 'https://id.twitch.tv/oauth2/token') { + return new Promise<Response>((resolve) => { + answerToken = resolve + }) + } + return json(200) + } + + const oauth = new TwitchOAuth({ clientId: 'client-1', onTokenUpdated: () => { updated++ } }) + oauth.setTokenData({ accessToken: 'old-access', refreshToken: 'old-refresh', expiresAt: Date.now() - 1000, scope: [] }) + + const refresh = oauth.refreshAccessToken() + oauth.clearTokenData() + answerToken!(json(200, { access_token: 'late-access', refresh_token: 'late-refresh', expires_in: 3600, scope: [], token_type: 'bearer' })) + + await assert.rejects(refresh, /cancelled during token refresh/) + + assert.equal(oauth.getTokenData(), null) + assert.equal(updated, 0, 'onTokenUpdated never fires for a dropped refresh') + + const revoked = sent.filter((request) => request.url === 'https://id.twitch.tv/oauth2/revoke') + assert.equal(revoked.length, 2) + assert.deepEqual(revoked.map((request) => request.body.get('token')).sort(), ['late-access', 'late-refresh']) + + assert.equal(errors.mock.callCount(), 0, 'a cancelled refresh is not an error') + const logged = logs.mock.calls.map((call) => call.arguments.join(' ')).join('\n') + assert.ok(logged.includes('Token refresh dropped'), logged) + }) +}) diff --git a/tests/backend/persist.test.ts b/tests/backend/persist.test.ts index 4f6f93d..c8a3e15 100644 --- a/tests/backend/persist.test.ts +++ b/tests/backend/persist.test.ts @@ -1,14 +1,14 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' const testDir = mkdtempSync(join(tmpdir(), 'streamqueue-persist-')) process.chdir(testDir) -const { createFileStore, flushAllStores, ensureDataDirectory, _test } = await import('../../src/persist.js') +const { createFileStore, flushAllStores, ensureDataDirectory } = await import('../../src/persist.js') let counter = 0 const filePath = () => join(testDir, `store-${++counter}.json`) @@ -77,6 +77,19 @@ test('load()', async (t) => { assert.deepEqual(store.load([{ id: 'x' }]), [{ id: 'x' }]) }) + + await t.test('a file of the wrong kind is set aside, like a corrupt one, instead of being silently overwritten', (t2) => { + t2.mock.method(console, 'warn', () => {}) + const path = filePath() + writeFileSync(path, 'null') + const store = createFileStore<{ a: number }>(path) + + assert.deepEqual(store.load({ a: 1 }), { a: 1 }) + + assert.equal(existsSync(path), false) + const kept = readdirSync(testDir).filter((name) => name.startsWith(`${basename(path)}.corrupt-`)) + assert.equal(kept.length, 1) + }) }) test('scheduleSave() / flush()', async (t) => { @@ -302,79 +315,71 @@ test('transient write failures are retried', async (t) => { return error } - _test.setSaveRetryDelay(1) - await t.test('a rename blocked once (an antivirus letting go of the file) succeeds on the second attempt', async () => { const path = filePath() - const store = createFileStore<{ n: number }>(path) let calls = 0 - _test.setRename(async (from, to) => { - calls++ - if (calls === 1) throw busy() - return realRename(from, to) + const store = createFileStore<{ n: number }>(path, { + rename: async (from, to) => { + calls++ + if (calls === 1) throw busy() + return realRename(from, to) + }, + retryDelayMs: 1 }) - try { - store.scheduleSave(() => ({ n: 1 })) - await store.flush() + store.scheduleSave(() => ({ n: 1 })) + await store.flush() - assert.equal(calls, 2) - assert.deepEqual(readJson(path), { n: 1 }) - } finally { - _test.resetRename() - } + assert.equal(calls, 2) + assert.deepEqual(readJson(path), { n: 1 }) }) await t.test('a rename that keeps failing is given up after three attempts and the .tmp is cleaned up', async (t2) => { t2.mock.method(console, 'error', () => {}) const path = filePath() - const store = createFileStore<{ n: number }>(path) let calls = 0 - _test.setRename(async () => { - calls++ - throw busy() + const store = createFileStore<{ n: number }>(path, { + rename: async () => { + calls++ + throw busy() + }, + retryDelayMs: 1 }) const errors: unknown[] = [] - try { - store.scheduleSave( - () => ({ n: 1 }), - (error) => errors.push(error) - ) - await store.flush() + store.scheduleSave( + () => ({ n: 1 }), + (error) => errors.push(error) + ) + await store.flush() - assert.equal(calls, 3) - assert.equal(errors.length, 1) - assert.equal(existsSync(`${path}.tmp`), false) - } finally { - _test.resetRename() - } + assert.equal(calls, 3) + assert.equal(errors.length, 1) + assert.equal(existsSync(`${path}.tmp`), false) }) await t.test('a non-transient failure is not retried at all', async () => { const path = filePath() - const store = createFileStore<{ n: number }>(path) let calls = 0 - _test.setRename(async () => { - calls++ - const error = new Error('is a directory') as Error & { code: string } - error.code = 'EISDIR' - throw error + const store = createFileStore<{ n: number }>(path, { + rename: async () => { + calls++ + const error = new Error('is a directory') as Error & { code: string } + error.code = 'EISDIR' + throw error + }, + retryDelayMs: 1 }) const errors: unknown[] = [] - try { - store.scheduleSave( - () => ({ n: 1 }), - (error) => errors.push(error) - ) - await store.flush() + store.scheduleSave( + () => ({ n: 1 }), + (error) => errors.push(error) + ) + await store.flush() - assert.equal(calls, 1) - assert.equal(errors.length, 1) - } finally { - _test.resetRename() - } + assert.equal(calls, 1) + assert.equal(errors.length, 1) }) }) diff --git a/tests/backend/youtube/quota.test.ts b/tests/backend/youtube/quota.test.ts index 86263cc..7d1084e 100644 --- a/tests/backend/youtube/quota.test.ts +++ b/tests/backend/youtube/quota.test.ts @@ -202,6 +202,31 @@ test('a change of the filters does not throw away paid-for requests', async (t) }) }) +test('a query that normalization empties still searches', async (t) => { + await t.test('searchSongs("!!!") sends q=!!! to the API', async () => { + let asked: string | null = null + globalThis.fetch = async (input) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname === 'www.googleapis.com' && url.pathname.endsWith('/search')) asked = url.searchParams.get('q') + return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + + assert.deepEqual(await searchSongs('!!!'), []) + assert.equal(asked, '!!!') + }) + + await t.test('a query of only whitespace is still nothing, and nothing is sent', async () => { + let sent = 0 + globalThis.fetch = async () => { + sent++ + return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + + assert.deepEqual(await searchSongs(' '), []) + assert.equal(sent, 0) + }) +}) + // the last block in the file: these tests set the quota flag, and it stays set for the process test('the quota flag gates every request to YouTube, not only searches', async (t) => { await t.test('a network failure does not exhaust the quota: the next request still goes out', async () => { diff --git a/tests/backend/youtube/url.test.ts b/tests/backend/youtube/url.test.ts index 9f58223..ded1441 100644 --- a/tests/backend/youtube/url.test.ts +++ b/tests/backend/youtube/url.test.ts @@ -108,6 +108,17 @@ test('parsePlaylistId', async (t) => { assert.equal(parsePlaylistId('dQw4w9WgXcQ'), null) }) + await t.test('a list param on a foreign host is ignored', () => { + assert.equal(parsePlaylistId('https://example.com/?list=PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb'), null) + }) + + await t.test('list links keep working on every YouTube host', () => { + const id = 'PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb' + assert.equal(parsePlaylistId(`https://youtube.com/playlist?list=${id}`), id) + assert.equal(parsePlaylistId(`https://music.youtube.com/playlist?list=${id}`), id) + assert.equal(parsePlaylistId(`https://youtu.be/dQw4w9WgXcQ?list=${id}`), id) + }) + await t.test('empty string', () => { assert.equal(parsePlaylistId(''), null) }) From ae999babd67d67eafe543961eea28e06b81e391b Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:30:18 +0700 Subject: [PATCH 08/22] Standardize HTTP errors and harden request handling Error handling: - make HTTP error codes exhaustive for status mapping and localized messages - centralize ID validation - return HTTP 429 when SSE connection limits are exceeded Queue and redemptions: - store track failure reasons as plain data - prevent refunds for Channel Point redemptions that fail after entering the queue --- public/locales/en.json | 1 + public/locales/ru.json | 1 + src/chat-replies.ts | 4 +- src/fallback.ts | 2 +- src/finish.ts | 12 +-- src/http.ts | 42 ++++++--- src/integrations/twitch/chat.ts | 4 +- src/integrations/twitch/client.ts | 4 +- src/integrations/twitch/index.ts | 57 +++++++------ src/player.ts | 8 +- src/queue.ts | 25 +++--- src/routes/blocklist.ts | 2 +- src/routes/fallback.ts | 2 +- src/routes/player.ts | 17 ++-- src/routes/playlists.ts | 19 ++--- src/routes/queue.ts | 4 +- src/routes/settings.ts | 10 +-- src/routes/twitch.ts | 2 +- src/secrets.ts | 2 +- src/sse.ts | 4 +- src/types.ts | 25 ++++-- src/validate.ts | 19 +++-- src/youtube/client.ts | 8 +- tests/backend/chat-replies.test.ts | 10 +-- tests/backend/error-codes.test.ts | 13 ++- .../integrations/twitch/redemptions.test.ts | 85 ++++++++++++++++++- tests/backend/routes/player.test.ts | 12 ++- tests/backend/routes/playlists.test.ts | 25 ++++++ tests/backend/routes/queue.test.ts | 23 +++++ tests/backend/sse.test.ts | 5 +- 30 files changed, 317 insertions(+), 130 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 5bd56b0..7853436 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -244,6 +244,7 @@ "queueItemNotFound": "Queue item not found", "trackNotFound": "Track not found in fallback", "serverError": "Internal server error", + "tooManyConnections": "Too many open connections, close a tab and try again", "unexpectedError": "Something went wrong, see the log for details", "notFound": "Not found", "duplicate": "This track is already in the queue", diff --git a/public/locales/ru.json b/public/locales/ru.json index 47ce638..9dcb2d2 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -244,6 +244,7 @@ "queueItemNotFound": "Элемент очереди не найден", "trackNotFound": "Трек не найден в fallback", "serverError": "Внутренняя ошибка сервера", + "tooManyConnections": "Слишком много открытых соединений, закройте вкладку и попробуйте снова", "unexpectedError": "Что-то пошло не так, подробности в логе", "notFound": "Не найдено", "duplicate": "Этот трек уже находится в очереди", diff --git a/src/chat-replies.ts b/src/chat-replies.ts index d114d85..d0601f2 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -1,5 +1,5 @@ import { t, hasKey } from './i18n.js' -import { AppError, type PlayerState } from './types.js' +import { type FailureReason, type PlayerState } from './types.js' const truncate = (s: string, max = 40) => { const chars = Array.from(s) @@ -52,7 +52,7 @@ export function buildRedemptionAcceptedMessage( // "@user, <reason>, points refunded" - one message per refusal reason, shared by the Channel Points // redemption flow (request rejected, or a later playback failure) wherever it needs to notify chat -export function buildRedemptionRejectionMessage(userName: string, reason: AppError, locale: string): string { +export function buildRedemptionRejectionMessage(userName: string, reason: FailureReason, locale: string): string { // only a playback failure names the track (the one that could not be played); a request that was never queued has none const title = typeof reason.params?.title === 'string' ? truncate(reason.params.title) : undefined const params = { user: userName, ...reason.params, ...(title && { title }) } diff --git a/src/fallback.ts b/src/fallback.ts index 100005c..3e40db9 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -221,7 +221,7 @@ export function playFallbackTrackNow(videoId: string): QueueItem | null { if (!song) return null if (isBlocked(videoId)) { - throw new AppError('BLOCKED', 'this track is blocked') + throw new AppError('BLOCKED') } fallbackCursor = index diff --git a/src/finish.ts b/src/finish.ts index 99e159b..9d3d635 100644 --- a/src/finish.ts +++ b/src/finish.ts @@ -1,10 +1,10 @@ -import { AppError, QueueItem } from './types.js' +import type { FailureReason, QueueItem } from './types.js' import { createLogger, describeError } from './logger.js' type Tracked = NonNullable<QueueItem['channelPointsRedemption']> -export type RedemptionOutcome = { status: 'succeeded' } | { status: 'failed'; reason: AppError } -export type ItemOutcome = 'played' | AppError +export type RedemptionOutcome = { status: 'succeeded' } | { status: 'failed'; reason: FailureReason } +export type ItemOutcome = 'played' | FailureReason const log = createLogger('FINISH') @@ -18,7 +18,7 @@ export function registerRedemptionHandler(next: typeof handler): void { /** * The single place where a track leaves the queue or the player for good. - * 'played' fulfils its Channel Points redemption, any thrown reason cancels it (points refunded, requester told in chat). + * 'played' fulfils its Channel Points redemption, any reason cancels it (points refunded, requester told in chat). * Tracks without a redemption (chat requests, playlist tracks) are ignored. * * The track owns its reward: once closed it is gone from the track, so no other path can close it again. @@ -31,8 +31,8 @@ export function finishItem(item: QueueItem | null | undefined, outcome: ItemOutc delete item.channelPointsRedemption // the chat reply names the track, whatever reason the caller knew about - const reason = outcome === 'played' ? null : new AppError(outcome.code, outcome.message, { title: item.title, ...outcome.params }) - const result: RedemptionOutcome = reason ? { status: 'failed', reason } : { status: 'succeeded' } + const result: RedemptionOutcome = + outcome === 'played' ? { status: 'succeeded' } : { status: 'failed', reason: { ...outcome, params: { title: item.title, ...outcome.params } } } try { handler?.(tracked, result) diff --git a/src/http.ts b/src/http.ts index af5fc91..a0f9aba 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,5 +1,5 @@ import { Response } from 'express-serve-static-core' -import { AppError, AppErrorCode, ApiOk, ApiError, ConfigUpdateResponse, FilterFailureReason } from './types.js' +import { AppError, HttpErrorCode, ApiOk, ApiError, ConfigUpdateResponse, FilterFailureReason } from './types.js' import { getSettings } from './settings.js' import { translateApiError } from './i18n.js' @@ -25,12 +25,12 @@ export function sendConfigUpdate<T extends object>(res: Response, config: T, rej params, data } - res.status(STATUS_BY_CODE.INVALID_CONFIG ?? 400).json(body) + res.status(STATUS_BY_CODE.INVALID_CONFIG).json(body) } -// the single place that knows which HTTP status an error code answers with; -// the chat-only reasons (playback, track lifecycle) never appear here and fall back to 500 -const STATUS_BY_CODE: Partial<Record<AppErrorCode, number>> = { +// the single place that knows which HTTP status an error code answers with; Record makes a missing row a +// compile error, so a new code cannot silently fall back to 500 +const STATUS_BY_CODE: Record<HttpErrorCode, number> = { DUPLICATE: 409, QUEUE_FULL: 409, USER_LIMIT: 409, @@ -62,20 +62,40 @@ const STATUS_BY_CODE: Partial<Record<AppErrorCode, number>> = { PAYLOAD_TOO_LARGE: 413, INVALID_REQUEST: 400, SERVER_ERROR: 500, - ...filterStatuses(['NOT_MUSIC', 'NOT_PUBLIC', 'NOT_EMBEDDABLE', 'AGE_RESTRICTED', 'REGION_BLOCKED', 'NOT_PLAYABLE', 'IS_LIVE', 'IS_SHORT', 'DURATION_OUT_OF_RANGE', 'VIEWS_TOO_LOW'], 404) + TOO_MANY_CONNECTIONS: 429, + ...filterStatuses( + [ + 'NOT_MUSIC', + 'NOT_PUBLIC', + 'NOT_EMBEDDABLE', + 'AGE_RESTRICTED', + 'REGION_BLOCKED', + 'NOT_PLAYABLE', + 'IS_LIVE', + 'IS_SHORT', + 'DURATION_OUT_OF_RANGE', + 'VIEWS_TOO_LOW' + ], + 404 + ) } -export function statusForCode(code: AppErrorCode): number { - return STATUS_BY_CODE[code] ?? 500 +// The single way to ask which status a code answers with; `fail` and the error logging agree through it +export function statusForCode(code: HttpErrorCode): number { + return STATUS_BY_CODE[code] } -function filterStatuses(codes: readonly FilterFailureReason[], status: number): Partial<Record<AppErrorCode, number>> { - return Object.fromEntries(codes.map((code) => [code, status])) +// every code the API can answer with, taken from the table itself: the locale tests walk it +export const HTTP_ERROR_CODES: readonly HttpErrorCode[] = Object.keys(STATUS_BY_CODE) as HttpErrorCode[] + +// the filter reasons all answer with the same status, so they stay one group +function filterStatuses(codes: readonly FilterFailureReason[], status: number): Record<FilterFailureReason, number> { + return Object.fromEntries(codes.map((code) => [code, status])) as Record<FilterFailureReason, number> } // The one way an error leaves the app: the errorHandler passes the thrown AppError here, the few // middleware that answer themselves (localOnly, hostCheck, the SSE connection limit) call it directly -export function fail(res: Response, code: AppErrorCode, params?: Record<string, string | number>, status = statusForCode(code)): void { +export function fail(res: Response, code: HttpErrorCode, params?: Record<string, string | number>, status = statusForCode(code)): void { const locale = getSettings().locale const body: ApiError = { success: false, error: translateApiError(locale, code, params), code, params } res.status(status).json(body) diff --git a/src/integrations/twitch/chat.ts b/src/integrations/twitch/chat.ts index cd6a1aa..8a1027e 100644 --- a/src/integrations/twitch/chat.ts +++ b/src/integrations/twitch/chat.ts @@ -85,7 +85,7 @@ export class TwitchChat extends ReconnectingSocket { async sendMessage(text: string): Promise<void> { if (!this.isConnected() || !this.socket) { - throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch chat is not connected') + throw new AppError('TWITCH_NOT_CONNECTED') } // a line break would end the IRC command and let the rest be read as a new one; Twitch drops anything over 500 characters @@ -104,7 +104,7 @@ export class TwitchChat extends ReconnectingSocket { protected async beforeOpen(): Promise<void> { const accessToken = await this.config.oauth.getValidAccessToken() - if (!accessToken) throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected, cannot open chat connection') + if (!accessToken) throw new AppError('TWITCH_NOT_CONNECTED') this.accessToken = accessToken } diff --git a/src/integrations/twitch/client.ts b/src/integrations/twitch/client.ts index 79665e4..e7ef3f1 100644 --- a/src/integrations/twitch/client.ts +++ b/src/integrations/twitch/client.ts @@ -32,7 +32,7 @@ export class TwitchClient { private async makeAuthenticatedRequest<T>(url: string, options: RequestInit = {}): Promise<T> { const accessToken = await this.oauth.getValidAccessToken() if (!accessToken) { - const notConnected = new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected') + const notConnected = new AppError('TWITCH_NOT_CONNECTED') // a login exists and Twitch has not refused it: a failed refresh was a passing problem, the request may work later throw this.refreshMayWorkLater() ? markTransient(notConnected) : notConnected } @@ -99,7 +99,7 @@ export class TwitchClient { } private get broadcasterId(): string { - if (!this.userInfo) throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected') + if (!this.userInfo) throw new AppError('TWITCH_NOT_CONNECTED') return this.userInfo.id } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 7726694..0a9e714 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -23,7 +23,7 @@ import { import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' import { getState, skipCurrent } from '../../player.js' import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish.js' -import { AppError } from '../../types.js' +import { AppError, type AddedSong, type FailureReason } from '../../types.js' import type { QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { emit, notify } from '../../state-events.js' @@ -139,35 +139,42 @@ async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemptio const query = event.user_input.trim() if (!query) { log.warn(`Empty song request in redemption: ${event.id}`) - await cancelRedemption(tracked, twitchClient, new AppError('SONG_NOT_FOUND', 'the song request was empty')) + await cancelRedemption(tracked, twitchClient, { code: 'SONG_NOT_FOUND' }) return } + let added: AddedSong try { - const added = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) - - notify({ type: 'viewerRequest', user: event.user_name, title: added.song.title }) - - // The account can be disconnected while the song is being looked up. The track is queued by now, but there is - // no login left to close the redemption with, so it is left for the streamer like the others - if (!oauth?.isAuthenticated()) { - const detached = detachChannelPointsRedemptions() - log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) - return - } - - log.log(`Redemption ${event.id} added to queue`) - const locale = getSettings().locale - await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) + // only the request itself: past this point the track is queued and the points are spent, so a later + // failure must not refund them (the track would play for free and close a refunded reward) + added = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) } catch (error) { - // a refusal (a duplicate, a limit, a filter) is routine: the points go back and the viewer is told why; - // anything else is an outage, and requestSong has already logged its details - const say = error instanceof AppError ? log.warn : log.error - say(`Song request failed for redemption ${event.id}: ${describeError(error)}`) + // a refusal (a duplicate, a limit, a filter) is routine: the points go back and the viewer is told why. + // Anything else is an outage: this catch is the only place its details are logged + if (error instanceof AppError) { + log.warn(`Song request failed for redemption ${event.id}: ${describeError(error)}`) + } else { + log.error(`Song request failed for redemption ${event.id}: ${describeError(error, true)}`) + } - const reason = error instanceof AppError ? error : new AppError('SERVER_ERROR', 'the request failed unexpectedly') + const reason: FailureReason = error instanceof AppError ? error : { code: 'SERVER_ERROR' } await cancelRedemption(tracked, twitchClient, reason) + return } + + notify({ type: 'viewerRequest', user: event.user_name, title: added.song.title }) + + // The account can be disconnected while the song is being looked up. The track is queued by now, but there is + // no login left to close the redemption with, so it is left for the streamer like the others + if (!oauth?.isAuthenticated()) { + const detached = detachChannelPointsRedemptions() + log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) + return + } + + log.log(`Redemption ${event.id} added to queue`) + const locale = getSettings().locale + await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) } let eventSubStart: Promise<void> | null = null @@ -374,7 +381,7 @@ function cancelDeviceAuthorization(): void { export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeResponse> { if (!oauth || !client) { - throw new AppError('TWITCH_AUTH_ERROR', 'Twitch integration not initialized') + throw new AppError('TWITCH_AUTH_ERROR') } if (getPublicSecretsView().twitch.connected) { @@ -552,7 +559,7 @@ async function fulfillRedemption(redemption: TrackedRedemption, twitchClient: Tw log.error(`Redemption ${redemption.id} was played but could not be fulfilled, it is left for a manual decision`) } -async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: AppError): Promise<void> { +async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: FailureReason): Promise<void> { const result = await setRedemptionStatus(redemption, twitchClient, 'CANCELED') // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise @@ -621,7 +628,7 @@ export async function disconnect(): Promise<number> { } export async function refreshConnection(): Promise<TwitchUserInfo> { - if (!oauth || !client) throw new AppError('TWITCH_REFRESH_ERROR', 'Twitch integration not initialized') + if (!oauth || !client) throw new AppError('TWITCH_REFRESH_ERROR') try { const userInfo = await client.getUserInfo() diff --git a/src/player.ts b/src/player.ts index 89a1557..774a4b9 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,4 +1,4 @@ -import { StateResponse, QueueItem, ActivityReasonCode, AppError } from './types.js' +import { StateResponse, QueueItem, ActivityReasonCode } from './types.js' import { isBlocked, markBlocked } from './blocklist.js' import { logRejection, logFailure } from './activity.js' import { getQueue, getCurrent, getIsPaused, setCurrent, shiftQueue } from './queue.js' @@ -27,7 +27,7 @@ export function moveToNext(): QueueItem | null { while (next && isBlocked(next.videoId)) { log.log(`skipped blocked track in queue: "${next.title}"`) logRejection(next.requestedBy, next.title, 'BLOCKED', { title: next.title, videoId: next.videoId }) - finishItem(next, new AppError('BLOCKED', next.title)) + finishItem(next, { code: 'BLOCKED' }) next = shiftQueue() } @@ -56,7 +56,7 @@ export function skipIfCurrent(videoId: string): boolean { if (current?.videoId !== videoId) return false log.log(`current track was blocked, skipping: "${current.title}"`) - finishItem(current, new AppError('BLOCKED', current.title)) + finishItem(current, { code: 'BLOCKED' }) moveToNext() return true } @@ -103,7 +103,7 @@ export function reportPlaybackFailure(errorCode?: number, videoId?: string): boo log.error(`playback failed: "${failed.title}" (error ${errorCode ?? 'unknown'})`) logFailure(failed, reasonCode, errorCode !== undefined ? { errorCode } : undefined) - finishItem(failed, new AppError(reasonCode, failed.title)) + finishItem(failed, { code: reasonCode, params: errorCode !== undefined ? { errorCode } : undefined }) } moveToNext() diff --git a/src/queue.ts b/src/queue.ts index 6200a28..8e8bdfe 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -6,7 +6,7 @@ import { QueueItem, Song, AppError, AddedSong } from './types.js' import { isBlocked } from './blocklist.js' import { emit } from './state-events.js' import { finishItem } from './finish.js' -import { createLogger, describeError } from './logger.js' +import { createLogger } from './logger.js' let currentSong: QueueItem | null = null const queue: QueueItem[] = [] @@ -94,28 +94,26 @@ function assertCanRequestSong(requestedBy: string, { bypassLimits = false }: Add const config = getConfig() if (currentSong !== null && queue.length >= config.maxQueueSize) { - throw new AppError('QUEUE_FULL', 'the queue is full') + throw new AppError('QUEUE_FULL') } if (bypassLimits) return const activeCount = getUserActiveCount(requestedBy) if (config.maxRequestsPerUser > 0 && activeCount >= config.maxRequestsPerUser) { - throw new AppError('USER_LIMIT', `you can only queue ${config.maxRequestsPerUser} track(s) at a time`, { - count: config.maxRequestsPerUser - }) + throw new AppError('USER_LIMIT', undefined, { count: config.maxRequestsPerUser }) } } function assertNotDuplicate(videoId: string): void { if (isActiveNonFallback(videoId) || isQueued(videoId)) { - throw new AppError('DUPLICATE', 'this track is already in the queue') + throw new AppError('DUPLICATE') } } function assertNotBlocked(videoId: string): void { if (isBlocked(videoId)) { - throw new AppError('BLOCKED', 'this track is blocked') + throw new AppError('BLOCKED') } } @@ -178,7 +176,7 @@ export function removeAt(index: number): QueueItem | null { if (item) { log.log(`removed "${item.title}" at position ${index + 1}`) - finishItem(item, new AppError('TRACK_REMOVED', item.title)) + finishItem(item, { code: 'TRACK_REMOVED' }) } emit('state') @@ -192,7 +190,7 @@ export function clearQueue(): QueueItem[] { log.log(`cleared ${cleared.length} songs`) - for (const item of cleared) finishItem(item, new AppError('QUEUE_CLEARED', item.title)) + for (const item of cleared) finishItem(item, { code: 'QUEUE_CLEARED' }) emit('state') @@ -218,7 +216,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) if (isYouTube && !videoId) { - throw new AppError('INVALID_YOUTUBE_URL', 'the query is a YouTube link without a video id') + throw new AppError('INVALID_YOUTUBE_URL') } if (videoId) { @@ -233,7 +231,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi ) } - if (!song) throw new AppError('SONG_NOT_FOUND', 'no matching track was found') + if (!song) throw new AppError('SONG_NOT_FOUND') const { item, started } = addSong(song, requestedBy, { bypassLimits: bypassFilters, channelPointsRedemption }) @@ -242,9 +240,8 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi return { song: item, started, position: started ? 0 : queue.length } } catch (error) { - // an expected refusal (duplicate, limit, filter) is routine and logRejection() writes it down; anything else is a bug or an outage and keeps its details - if (!(error instanceof AppError)) log.error(`Unexpected error while adding a song for ${requestedBy}: ${describeError(error, true)}`) - + // an expected refusal (duplicate, limit, filter) is routine and logRejection() writes it down; anything + // else is a bug or an outage, and the handler that owns the request logs it once with its details logRejection(requestedBy, query, error instanceof AppError ? error.code : 'SERVER_ERROR', { title: song?.title ?? null, videoId: song?.videoId ?? null, diff --git a/src/routes/blocklist.ts b/src/routes/blocklist.ts index a3a3510..0c4c824 100644 --- a/src/routes/blocklist.ts +++ b/src/routes/blocklist.ts @@ -26,6 +26,6 @@ router.post('/', (req, res) => { router.delete('/:videoId', (req, res) => { const removed = unblockTrack(req.params.videoId) - if (!removed) throw new AppError('NOT_FOUND', 'no blocked track has that id') + if (!removed) throw new AppError('NOT_FOUND') ok(res, { removed: true }) }) diff --git a/src/routes/fallback.ts b/src/routes/fallback.ts index 2aac9bc..d15ee6d 100644 --- a/src/routes/fallback.ts +++ b/src/routes/fallback.ts @@ -14,7 +14,7 @@ import { import { getState } from '../player.js' function fallbackTrackOrNotFound(item: QueueItem | null): QueueItem { - if (!item) throw new AppError('NOT_FOUND', 'no track with that id is in the playlist') + if (!item) throw new AppError('NOT_FOUND') return item } diff --git a/src/routes/player.ts b/src/routes/player.ts index 8d34975..7f244c5 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -1,25 +1,25 @@ import express from 'express' -import { PlayerActionResponse, AppError } from '../types.js' +import { PlayerActionResponse } from '../types.js' import { ok } from '../http.js' -import { isValidVideoId } from '../youtube/url.js' import { t } from '../i18n.js' import { buildSkipMessage } from '../chat-replies.js' import { setPaused } from '../queue.js' import { getState, endCurrent, skipCurrent, reportPlaybackFailure } from '../player.js' import { getSettings } from '../settings.js' +import { checkVideoId } from '../validate.js' export const router = express.Router() // `videoId` says which track the client is talking about; without it the report is taken to be about the current one -function readVideoId(body: unknown): { videoId?: string; valid: boolean } { +function readVideoId(body: unknown): string | undefined { const videoId = (body as { videoId?: unknown } | undefined)?.videoId - if (videoId === undefined) return { valid: true } - return isValidVideoId(videoId) ? { videoId, valid: true } : { valid: false } + if (videoId === undefined) return undefined + checkVideoId(videoId) + return videoId } router.post('/ended', (req, res) => { - const { videoId, valid } = readVideoId(req.body) - if (!valid) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') + const videoId = readVideoId(req.body) const applied = endCurrent(videoId) ok(res, { ...getState(), ...(applied ? {} : { ignored: true }) }) @@ -45,8 +45,7 @@ router.post('/resume', (_req, res) => { }) router.post('/report-failure', (req, res) => { - const { videoId, valid } = readVideoId(req.body) - if (!valid) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') + const videoId = readVideoId(req.body) const errorCode = typeof req.body?.errorCode === 'number' ? req.body.errorCode : undefined const applied = reportPlaybackFailure(errorCode, videoId) diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index 6c5913f..ab6d8dd 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -2,12 +2,11 @@ import express from 'express' import { ConfigResponse, AppError } from '../types.js' import { ok } from '../http.js' import { getConfig, updateConfig } from '../config.js' -import { parsePlaylistId } from '../youtube/url.js' import { fetchPlaylistMeta } from '../youtube/index.js' import { getPlaylists, upsertPlaylist, removePlaylist, type PlaylistEntry } from '../playlists.js' import { clearFallback, refreshFallbackOrRollback } from '../fallback.js' import { createLogger } from '../logger.js' -import { checkPlaylistId, validatePlaylistIdParam } from '../validate.js' +import { checkPlaylistIdOrUrl, validatePlaylistIdParam } from '../validate.js' import { emit } from '../state-events.js' const log = createLogger('PLAYLISTS') @@ -27,16 +26,11 @@ router.get('/', (_req, res) => { }) router.post('/', async (req, res) => { - const raw = typeof req.body?.playlistId === 'string' ? req.body.playlistId : '' - const parsedId = parsePlaylistId(raw) - - if (!parsedId) { - throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') - } + const parsedId = checkPlaylistIdOrUrl(req.body?.playlistId) const meta = await fetchPlaylistMeta(parsedId) if (!meta) { - throw new AppError('PLAYLIST_NOT_FOUND', 'no playlist has that id') + throw new AppError('PLAYLIST_NOT_FOUND') } const saved = upsertPlaylist(meta) @@ -46,7 +40,7 @@ router.post('/', async (req, res) => { router.delete('/:id', (req, res) => { const removed = removePlaylist(req.params.id) if (!removed) { - throw new AppError('PLAYLIST_NOT_FOUND', 'no saved playlist has that id') + throw new AppError('PLAYLIST_NOT_FOUND') } const wasActive = getConfig().fallbackPlaylist.playlistId === req.params.id @@ -62,10 +56,9 @@ router.delete('/:id', (req, res) => { router.post('/:id/activate', async (req, res) => { const { id } = req.params - checkPlaylistId(id) - + // the shape of :id was already checked by the shared router.param before the route runs if (!getPlaylists().some((playlist) => playlist.id === id)) { - throw new AppError('PLAYLIST_NOT_FOUND', 'no saved playlist has that id') + throw new AppError('PLAYLIST_NOT_FOUND') } const previousId = getConfig().fallbackPlaylist.playlistId diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 5eab1f9..8dfc2c3 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -18,7 +18,7 @@ router.post('/request', async (req, res) => { const checkedQuery = checkQuery(query) if (typeof requestedBy !== 'string' || requestedBy.trim().length === 0) { - throw new AppError('USERNAME_REQUIRED', 'a username is required') + throw new AppError('USERNAME_REQUIRED') } const added = await requestSong(checkedQuery, requestedBy.trim().slice(0, 50), { bypassFilters }) @@ -33,7 +33,7 @@ router.delete('/video/:videoId', (req, res) => { const removed = removeByVideoId(req.params.videoId) if (!removed) { - throw new AppError('QUEUE_ITEM_NOT_FOUND', 'no queued track has that id') + throw new AppError('QUEUE_ITEM_NOT_FOUND') } ok<QueueRemoveResponse>(res, { removed, state: getState() }) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index bdca9ad..76d1040 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -5,7 +5,7 @@ import { isLocalAddress, localOnly } from '../local-only.js' import { getConfig, updateConfig } from '../config.js' import { getSettings, updateSettings } from '../settings.js' import { getPublicSecretsView, SecretsResponse, updateSecrets } from '../secrets.js' -import { parsePlaylistId } from '../youtube/url.js' +import { checkPlaylistIdOrUrl } from '../validate.js' import { refreshFallbackOrRollback, reorderFallback } from '../fallback.js' export const router = express.Router() @@ -29,18 +29,14 @@ router.put('/config', async (req, res) => { if (body.fallbackPlaylist !== undefined) { if (typeof body.fallbackPlaylist !== 'object' || body.fallbackPlaylist === null || Array.isArray(body.fallbackPlaylist)) { - throw new AppError('INVALID_CONFIG', 'the fallbackPlaylist field is not an object', { fields: 'fallbackPlaylist' }) + throw new AppError('INVALID_CONFIG', undefined, { fields: 'fallbackPlaylist' }) } if (typeof body.fallbackPlaylist.playlistId === 'string') { const rawPlaylistId = body.fallbackPlaylist.playlistId.trim() if (rawPlaylistId) { - const parsedId = parsePlaylistId(rawPlaylistId) - if (!parsedId) { - throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') - } - body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: parsedId } + body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: checkPlaylistIdOrUrl(rawPlaylistId) } } else { body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: null } } diff --git a/src/routes/twitch.ts b/src/routes/twitch.ts index 96596a9..8bd24d0 100644 --- a/src/routes/twitch.ts +++ b/src/routes/twitch.ts @@ -12,7 +12,7 @@ import type { TwitchClient } from '../integrations/twitch/client.js' function requireClient(): TwitchClient { const client = getClient() if (!client || !isTwitchConnected()) { - throw new AppError('TWITCH_NOT_CONNECTED', 'Twitch account is not connected') + throw new AppError('TWITCH_NOT_CONNECTED') } return client } diff --git a/src/secrets.ts b/src/secrets.ts index 86f03f0..c876608 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -62,7 +62,7 @@ const secrets = createConfigModule<Secrets>({ // Secrets are refused as a whole: either everything in an update is valid, or nothing is applied function apply(updates: DeepPartial<Secrets>): void { const { clean, rejected } = secrets.validateConfigUpdates(updates) - if (rejected.length) throw new AppError('INVALID_INPUT', `invalid secrets: ${rejected.join(', ')}`, { fields: rejected.join(', ') }) + if (rejected.length) throw new AppError('INVALID_INPUT', undefined, { fields: rejected.join(', ') }) secrets.updateConfig(clean) } diff --git a/src/sse.ts b/src/sse.ts index 66136bf..2074444 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -6,7 +6,7 @@ import { on, onNotice, TOPICS, type Notice, type Topic } from './state-events.js const log = createLogger('SSE') export type EventStreamOptions = { - // above this the answer is 503: a few forgotten tabs must not use up every connection of the server + // above this the answer is 429 (a limit, not a failure): a few forgotten tabs must not use up every connection of the server maxClients?: number // a quiet connection is kept alive (proxies, antivirus and OBS drop silent ones) and the page can tell it is still there pingEveryMs?: number @@ -95,7 +95,7 @@ export function createEventStreams({ maxClients = 20, pingEveryMs = 25_000, coal function handle(req: Request, res: Response): void { if (clients.size >= maxClients) { - fail(res, 'SERVER_ERROR', undefined, 503) + fail(res, 'TOO_MANY_CONNECTIONS') return } diff --git a/src/types.ts b/src/types.ts index 5bdf9bd..8b65a1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ -// The one set of error codes the app knows: what a route rejects with, what requestSong throws, -// what the activity log and the chat replies name. Chat-only reasons (playback, track lifecycle) never reach HTTP. -export type AppErrorCode = +// What a route can answer with and requestSong throws: every code here has a row in STATUS_BY_CODE and a +// key under api.errors.* in both locales +export type HttpErrorCode = | FilterFailureReason | 'DUPLICATE' | 'BLOCKED' @@ -33,6 +33,11 @@ export type AppErrorCode = | 'INVALID_JSON' | 'PAYLOAD_TOO_LARGE' | 'INVALID_REQUEST' + | 'TOO_MANY_CONNECTIONS' + +// Why a track ended without playing through (removed, cleared, blocked, playback failure) or an open reward +// was left: these never answer an HTTP request, they only name activity entries and chat replies +export type ChatReasonCode = | 'PLAYBACK_VIDEO_UNAVAILABLE' | 'PLAYBACK_EMBED_DISALLOWED' | 'PLAYBACK_FAILED' @@ -40,6 +45,8 @@ export type AppErrorCode = | 'QUEUE_CLEARED' | 'REWARD_NOT_REFUNDED' +export type AppErrorCode = HttpErrorCode | ChatReasonCode + export type ActivityReasonCode = AppErrorCode export type ActivityStatus = 'accepted' | 'rejected' | 'failed' | 'skipped' @@ -121,10 +128,18 @@ export type FilterFailureReason = | 'DURATION_OUT_OF_RANGE' | 'VIEWS_TOO_LOW' +// Why a track never played through or a request was refused, carried to finishItem and the chat reply. +// Never thrown, so there is no message and no stack: a thrown refusal is an AppError, which fits this shape +// wherever a plain reason is expected +export type FailureReason = { + code: AppErrorCode + params?: Record<string, string | number> +} + export class AppError extends Error { constructor( - public code: AppErrorCode, - message: string, + public code: HttpErrorCode, + message: string = code, public params?: Record<string, string | number> ) { super(message) diff --git a/src/validate.ts b/src/validate.ts index 53d72d8..233fa3f 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,23 +1,30 @@ import type { NextFunction, Request, Response } from 'express' import { AppError } from './types.js' -import { isValidPlaylistId, isValidVideoId } from './youtube/url.js' +import { isValidPlaylistId, isValidVideoId, parsePlaylistId } from './youtube/url.js' // The shared request rules, so two routes that take the same kind of input cannot drift apart: // every route with a `:videoId` param goes through validateVideoIdParam, every query through checkQuery -export function checkVideoId(value: unknown): void { - if (!isValidVideoId(value)) throw new AppError('INVALID_VIDEO_ID', 'a valid videoId is required') +export function checkVideoId(value: unknown): asserts value is string { + if (!isValidVideoId(value)) throw new AppError('INVALID_VIDEO_ID') } export function checkPlaylistId(value: unknown): void { - if (!isValidPlaylistId(value)) throw new AppError('INVALID_PLAYLIST_ID', 'a valid playlist ID or URL is required') + if (!isValidPlaylistId(value)) throw new AppError('INVALID_PLAYLIST_ID') +} + +// "a valid playlist ID or URL": the one rule for the routes that accept either form, which return the parsed id +export function checkPlaylistIdOrUrl(value: unknown): string { + const id = typeof value === 'string' ? parsePlaylistId(value) : null + if (id === null) throw new AppError('INVALID_PLAYLIST_ID') + return id } export function checkQuery(value: unknown): string { const query = typeof value === 'string' ? value.trim() : '' - if (query.length > 200) throw new AppError('INVALID_QUERY_RANGE', 'the query must be between 2 and 200 characters') - if (query.length < 2) throw new AppError('INVALID_QUERY', 'the query must be at least 2 characters') + if (query.length > 200) throw new AppError('INVALID_QUERY_RANGE') + if (query.length < 2) throw new AppError('INVALID_QUERY') return query } diff --git a/src/youtube/client.ts b/src/youtube/client.ts index 2cc7ab0..a88f505 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -130,13 +130,11 @@ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]) const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 500 -const QUOTA_EXHAUSTED_MESSAGE = 'YouTube API quota exceeded. Quota resets at midnight Pacific Time' - export async function youtube<T>(path: string, params: Record<string, string>, { attempts = MAX_ATTEMPTS }: { attempts?: number } = {}): Promise<T> { const { youtubeApiKey } = getSecrets() - if (!youtubeApiKey) throw new AppError('NO_API_KEY', 'YouTube API key is not configured, add it in the control panel') - if (isQuotaExhausted()) throw new AppError('YOUTUBE_QUOTA', QUOTA_EXHAUSTED_MESSAGE) + if (!youtubeApiKey) throw new AppError('NO_API_KEY') + if (isQuotaExhausted()) throw new AppError('YOUTUBE_QUOTA') const url = new URL(`https://www.googleapis.com/youtube/v3/${path}`) @@ -171,7 +169,7 @@ export async function youtube<T>(path: string, params: Record<string, string>, { if (reason === 'quotaExceeded') { markQuotaExhausted() - throw new AppError('YOUTUBE_QUOTA', QUOTA_EXHAUSTED_MESSAGE) + throw new AppError('YOUTUBE_QUOTA') } throw new AppError('YOUTUBE_ERROR', data.error?.message || `YouTube API error ${response.status}`) diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 43ddd38..3dd4ad2 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -13,7 +13,6 @@ cpSync(locales, join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) const { buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage } = await import('../../src/chat-replies.js') -const { AppError } = await import('../../src/types.js') test('buildRedemptionAcceptedMessage', async (t) => { await t.test('names the track and its place in the queue', () => { @@ -33,24 +32,25 @@ test('buildRedemptionAcceptedMessage', async (t) => { }) test('buildRedemptionRejectionMessage', async (t) => { + // the reason is a plain { code, params } — a thrown AppError fits the same shape when one reaches this far await t.test('a request that was never queued gets only the reason', () => { - assert.equal(buildRedemptionRejectionMessage('alice', new AppError('DUPLICATE', 'already queued')), '@alice, that track is already in the queue, points refunded') + assert.equal(buildRedemptionRejectionMessage('alice', { code: 'DUPLICATE' }), '@alice, that track is already in the queue, points refunded') }) await t.test('a playback failure names the track and the reason', () => { - const message = buildRedemptionRejectionMessage('alice', new AppError('PLAYBACK_VIDEO_UNAVAILABLE', 'playback failed', { title: 'Song' })) + const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_VIDEO_UNAVAILABLE', params: { title: 'Song' } }) assert.equal(message, '@alice, “Song” could not be played: the video became unavailable, points refunded') }) await t.test('every playback failure reason names the track', () => { for (const code of ['PLAYBACK_VIDEO_UNAVAILABLE', 'PLAYBACK_EMBED_DISALLOWED', 'PLAYBACK_FAILED'] as const) { - const message = buildRedemptionRejectionMessage('alice', new AppError(code, 'playback failed', { title: 'Song' })) + const message = buildRedemptionRejectionMessage('alice', { code, params: { title: 'Song' } }) assert.ok(message.includes('“Song”') && !message.includes('{{'), code) } }) await t.test('a long title in a playback failure is shortened', () => { - const message = buildRedemptionRejectionMessage('alice', new AppError('PLAYBACK_FAILED', 'playback failed', { title: 'A'.repeat(100) })) + const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_FAILED', params: { title: 'A'.repeat(100) } }) assert.ok(message.includes(`“${'A'.repeat(39)}…”`)) }) }) diff --git a/tests/backend/error-codes.test.ts b/tests/backend/error-codes.test.ts index 6c42818..ae9ea93 100644 --- a/tests/backend/error-codes.test.ts +++ b/tests/backend/error-codes.test.ts @@ -1,6 +1,17 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { translateErrorCode } from '../../src/i18n.js' +import { HTTP_ERROR_CODES } from '../../src/http.js' + +// the table is the source of truth: a code that reaches the API must have a status (the Record type checks +// that at compile time) and a text in every locale (this checks it at run time) +test('every HTTP error code has its own text in both languages', () => { + assert.ok(HTTP_ERROR_CODES.length >= 40) + for (const code of HTTP_ERROR_CODES) { + assert.notEqual(translateErrorCode('en', code), null, `${code} (en)`) + assert.notEqual(translateErrorCode('ru', code), null, `${code} (ru)`) + } +}) test('API error codes map to their own message', async (t) => { await t.test('INVALID_REQUEST no longer pretends to be "username required"', () => { @@ -19,7 +30,7 @@ test('API error codes map to their own message', async (t) => { assert.equal(translateErrorCode('en', 'INVALID_VIDEO_ID'), 'Invalid video ID') assert.equal(translateErrorCode('ru', 'INVALID_VIDEO_ID'), 'Некорректный ID видео') - for (const code of ['INVALID_JSON', 'PAYLOAD_TOO_LARGE', 'FORBIDDEN_ORIGIN', 'LOCAL_ONLY']) { + for (const code of ['INVALID_JSON', 'PAYLOAD_TOO_LARGE', 'FORBIDDEN_ORIGIN', 'LOCAL_ONLY', 'TOO_MANY_CONNECTIONS']) { for (const locale of ['en', 'ru']) assert.ok(translateErrorCode(locale, code), `${code} (${locale})`) } }) diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index edd5a72..fc033c0 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -11,6 +11,7 @@ process.chdir(workDir) const { isRetryable, _test, _resetIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') const { buildRedemptionRejectionMessage, buildRedemptionRefundFailedMessage } = await import('../../../../src/chat-replies.js') const { AppError } = await import('../../../../src/types.js') +const { updateTwitchOAuthState, updateSecrets } = await import('../../../../src/secrets.js') const { blockTrack, unblockTrack } = await import('../../../../src/blocklist.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const queue = await import('../../../../src/queue.js') @@ -18,10 +19,14 @@ import type { TwitchChat } from '../../../../src/integrations/twitch/chat.js' import type { TwitchClient } from '../../../../src/integrations/twitch/client.js' const redemption = { id: 'red-1', rewardId: 'reward-1', userName: 'viewer' } -const reason = new AppError('PLAYBACK_FAILED', 'playback failed') +// the reason a redemption is canceled travels as a plain { code, params }, not as a thrown AppError +const reason = { code: 'PLAYBACK_FAILED' } const refunded = () => buildRedemptionRejectionMessage('viewer', reason) const refundFailed = () => buildRedemptionRefundFailedMessage('viewer') +// the song requests below reach the YouTube layer, which needs a key to get past its own gate +updateSecrets({ youtubeApiKey: 'test-key' }) + const api = (status: number, code = 'TWITCH_API_ERROR') => new AppError(code as 'TWITCH_API_ERROR', `Twitch answered ${status}`, { status }) let sent: string[] = [] @@ -201,4 +206,82 @@ test('handleChannelPointsRedemption()', async (t) => { unblockTrack(A) }) + + // YouTube is stubbed, so a request can actually be accepted: the fetch answers fail in a way that is nobody's + // refusal (a broken row from the videos endpoint) and the request becomes an unexpected error + const realFetch = globalThis.fetch + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + + await t.test('an unexpected request failure is logged once, and the viewer gets the generic refund message', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + return url.pathname.endsWith('/search') ? json({ items: [{ id: { videoId: 'aaaaaaaaaaa' } }] }) : json({ items: [null] }) + }) as typeof fetch + + try { + await _test.handleChannelPointsRedemption(event('red-broken', 'some song') as never, fakeClient('ok')) + } finally { + globalThis.fetch = realFetch + } + + assert.equal(errors.mock.callCount(), 1, 'the handler that owns the request logs the outage, nobody else') + assert.deepEqual(calls, ['CANCELED']) + assert.deepEqual(sent, [buildRedemptionRejectionMessage('viewer', { code: 'SERVER_ERROR' })]) + }) + + await t.test('a failure after the track is queued does not refund the points', async () => { + // a token in storage makes the account look authenticated, so the flow reaches the chat reply + await _resetIntegration() + updateTwitchOAuthState({ tokenData: { accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() + 3600_000, scope: [] } }) + initializeTwitchIntegration({ clientId: 'client-1' }) + updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) + + queue.clearQueue() + queue.setCurrent(null) + + // the chat connection breaks while the acceptance reply is being sent: the song is already playing + _test.setChat({ + isConnected: () => { + throw new Error('chat blew up') + }, + disconnect: async () => {}, + sendMessage: async () => {} + } as unknown as TwitchChat) + + const A = 'aaaaaaaaaaa' + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + return json({ + items: [ + { + id: A, + snippet: { title: `Song ${A}`, channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: 'https://img/x.jpg' } } }, + contentDetails: { duration: 'PT3M' }, + statistics: { viewCount: '1000000' } + } + ] + }) + }) as typeof fetch + + try { + // the broken chat must escape the handler instead of being mistaken for a failed request + await assert.rejects( + _test.handleChannelPointsRedemption(event('red-late', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')), + /chat blew up/ + ) + } finally { + globalThis.fetch = realFetch + _test.setChat(null) + await _resetIntegration() + updateTwitchOAuthState({ tokenData: null }) + } + + // the points were spent: the redemption was not canceled, and the track keeps playing with its reward open + assert.deepEqual(calls, []) + assert.equal(queue.getCurrent()?.videoId, A) + assert.equal(queue.getCurrent()?.channelPointsRedemption?.id, 'red-late') + }) }) diff --git a/tests/backend/routes/player.test.ts b/tests/backend/routes/player.test.ts index 602f5f8..57d49ea 100644 --- a/tests/backend/routes/player.test.ts +++ b/tests/backend/routes/player.test.ts @@ -85,10 +85,11 @@ test('POST /api/player/ended', async (t) => { assert.equal(currentId(), B) }) - await t.test('a malformed videoId is a 400', async () => { + await t.test('a malformed videoId is a 400 INVALID_VIDEO_ID', async () => { const response = await post('ended', { videoId: 'x' }) assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_VIDEO_ID') assert.equal(currentId(), A) }) }) @@ -124,4 +125,13 @@ test('POST /api/player/report-failure', async (t) => { assert.equal(body.data.ignored, true) }) + + await t.test('a malformed videoId is a 400 INVALID_VIDEO_ID', async () => { + const response = await post('report-failure', { videoId: 'x' }) + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_VIDEO_ID') + assert.equal(currentId(), A) + assert.equal(getActivity().length, 0, 'no playback failure is recorded for a malformed id') + }) }) diff --git a/tests/backend/routes/playlists.test.ts b/tests/backend/routes/playlists.test.ts index 1d0cda8..efe42dc 100644 --- a/tests/backend/routes/playlists.test.ts +++ b/tests/backend/routes/playlists.test.ts @@ -56,6 +56,31 @@ test('POST /api/playlists/:id/activate', async (t) => { const list = async () => ((await (await fetch(base)).json()) as Record<string, any>).data.playlists as Array<{ id: string; isActive: boolean }> +const create = (playlistId: unknown) => + fetch(base, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlistId }) + }) + +test('POST /api/playlists', async (t) => { + await t.test('a malformed playlist id is a 400 INVALID_PLAYLIST_ID from the shared ID-or-URL check', async () => { + for (const playlistId of ['not a playlist', 'aaaaaaaaaaa']) { + const response = await create(playlistId) + + assert.equal(response.status, 400, String(playlistId)) + assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_PLAYLIST_ID', String(playlistId)) + } + }) + + await t.test('a well-formed id or URL is parsed and goes on to load it (no key here, so the load refuses)', async () => { + const response = await create('https://www.youtube.com/playlist?list=PLsaved00000001') + + assert.equal(response.status, 400) + assert.equal(((await response.json()) as Record<string, any>).code, 'NO_API_KEY') + }) +}) + test('GET /api/playlists', async (t) => { await t.test('says for every playlist whether it is the active one', async () => { upsertPlaylist({ id: 'PLfirst000000001', title: 'First', thumbnail: '', itemCount: 1 }) diff --git a/tests/backend/routes/queue.test.ts b/tests/backend/routes/queue.test.ts index 15126c9..000c6ee 100644 --- a/tests/backend/routes/queue.test.ts +++ b/tests/backend/routes/queue.test.ts @@ -131,6 +131,29 @@ test('POST /api/queue/request', async (t) => { assert.equal(((await response.json()) as Record<string, any>).code, 'INVALID_YOUTUBE_URL') assert.equal(getActivity().find((entry) => entry.status === 'rejected')?.reasonCode, 'INVALID_YOUTUBE_URL') }) + + // a broken row from the videos endpoint fails the request in a way that is nobody's refusal: the request + // becomes an unexpected error, and the errorHandler is the one place it is logged + await t.test('an unexpected request failure is logged once and stays a 500', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + const stubbed = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return stubbed(input, init) + return url.pathname.endsWith('/search') ? json({ items: [{ id: { videoId: 'zzzzzzzzzzz' } }] }) : json({ items: [null] }) + }) as typeof fetch + + try { + const response = await request({ query: 'some song', requestedBy: 'amy' }) + + assert.equal(response.status, 500) + assert.equal(((await response.json()) as Record<string, any>).code, 'SERVER_ERROR') + assert.equal(errors.mock.callCount(), 1, 'the errorHandler is the only one that logs the outage') + } finally { + globalThis.fetch = stubbed + } + }) }) test('DELETE /api/queue/video/:videoId', async (t) => { diff --git a/tests/backend/sse.test.ts b/tests/backend/sse.test.ts index 236147f..af4ad1e 100644 --- a/tests/backend/sse.test.ts +++ b/tests/backend/sse.test.ts @@ -151,7 +151,7 @@ test('GET /api/events', async (t) => { } }) - await t.test('a client that goes away frees its place, the one over the limit gets 503', async () => { + await t.test('a client that goes away frees its place, the one over the limit gets 429 TOO_MANY_CONNECTIONS', async () => { const server = await startServer({ maxClients: 2 }) try { const first = await server.open() @@ -159,7 +159,8 @@ test('GET /api/events', async (t) => { await until(() => server.streams.clientCount() === 2, 'two clients') const refused = await server.open() - assert.equal(refused.status, 503) + assert.equal(refused.status, 429) + assert.equal(JSON.parse(refused.text()).code, 'TOO_MANY_CONNECTIONS') assert.equal(server.streams.clientCount(), 2) first.close() From 85bf294d89ff141ac07323ebeded0251162bef02 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:49:39 +0700 Subject: [PATCH 09/22] Centralize store updates and simplify SSE events State management: - add a shared store helper to detect meaningful changes, persist updates, and emit topic notifications consistently - use config changes to trigger fallback playlist loading, reordering, and rollback Real-time updates: - identify SSE events by topic without sequence counters --- README.md | 13 ++- src/activity.ts | 30 ++--- src/blocklist.ts | 50 ++++---- src/config-helper.ts | 37 +++++- src/config.ts | 14 ++- src/fallback.ts | 151 +++++++++++++++++++------ src/playlists.ts | 59 ++++++---- src/queue.ts | 68 ++++++----- src/routes/playlists.ts | 7 +- src/routes/settings.ts | 34 +----- src/secrets.ts | 5 +- src/settings.ts | 9 +- src/sse.ts | 5 +- src/state-events.ts | 2 +- src/store.ts | 29 +++++ tests/backend/announcements.test.ts | 37 +++++- tests/backend/blocklist.test.ts | 14 ++- tests/backend/fallback.refresh.test.ts | 62 +++++++++- tests/backend/fallback.restart.test.ts | 5 +- tests/backend/routes/playlists.test.ts | 14 ++- tests/backend/routes/settings.test.ts | 6 +- tests/backend/sse.test.ts | 6 +- 22 files changed, 455 insertions(+), 202 deletions(-) create mode 100644 src/store.ts diff --git a/README.md b/README.md index 6bfb126..ad3266a 100644 --- a/README.md +++ b/README.md @@ -191,14 +191,19 @@ Application state is stored as JSON next to the executable: ```text data/ config.json + settings.json + server.json + twitch-config.json playlists.json + blocklist.json + activity.json + queue-state.json secrets.json + logs/ + app.log cache/ - queue-state.json - youtube-cache.json - -logs/ + fallback-tracks.json ``` Your YouTube API key and Twitch OAuth state are stored locally. The application communicates with external services only when needed for its integrations, primarily the YouTube Data API and Twitch APIs. diff --git a/src/activity.ts b/src/activity.ts index 6fdf5a4..ab1c743 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,7 +1,7 @@ import { ActivityEntry, ActivityReasonCode, QueueItem } from './types.js' import { dataPath, createFileStore, loadList } from './persist.js' import { createLogger } from './logger.js' -import { emit } from './state-events.js' +import { createStore } from './store.js' const ACTIVITY_LIMIT = 100 @@ -31,18 +31,20 @@ const entries = (): ActivityEntry[] => (loaded ??= loadList(store, isActivityEnt const log = createLogger('ACTIVITY') -function save(): void { - store.scheduleSave(() => entries()) -} +const changes = createStore({ + read: entries, + topics: ['activity'], + save: () => store.scheduleSave(entries) +}) export function logActivity(entry: Omit<ActivityEntry, 'at'>): void { - const list = entries() - list.unshift({ ...entry, at: Date.now() }) - if (list.length > ACTIVITY_LIMIT) { - list.length = ACTIVITY_LIMIT - } - save() - emit('activity') + changes.update(() => { + const list = entries() + list.unshift({ ...entry, at: Date.now() }) + if (list.length > ACTIVITY_LIMIT) { + list.length = ACTIVITY_LIMIT + } + }) } export function getActivity(): ActivityEntry[] { @@ -50,9 +52,9 @@ export function getActivity(): ActivityEntry[] { } export function clearActivity(): void { - entries().length = 0 - save() - emit('activity') + changes.update(() => { + entries().length = 0 + }) log.log('cleared') } diff --git a/src/blocklist.ts b/src/blocklist.ts index 05803fb..b398e29 100644 --- a/src/blocklist.ts +++ b/src/blocklist.ts @@ -1,7 +1,7 @@ import { dataPath, createFileStore, loadList } from './persist.js' import { createLogger } from './logger.js' import { isValidVideoId } from './youtube/url.js' -import { emit } from './state-events.js' +import { createStore } from './store.js' import type { Blockable } from './types.js' export type BlockedTrack = { @@ -17,6 +17,13 @@ const TITLE_MAX_LENGTH = 200 const log = createLogger('BLOCKLIST') const store = createFileStore<BlockedTrack[]>(() => dataPath('blocklist.json')) +// a blocked track changes the queue view (it is skipped) and the list itself; the playlist view follows through 'state' +const changes = createStore({ + read: () => ensureLoaded().list, + topics: ['blocklist', 'state'], + save: () => store.scheduleSave(() => ensureLoaded().list) +}) + function isBlockedTrack(value: unknown): value is BlockedTrack { if (typeof value !== 'object' || value === null) return false const v = value as Record<string, unknown> @@ -36,10 +43,6 @@ function ensureLoaded(): { list: BlockedTrack[]; ids: Set<string> } { return { list: blocked, ids: blockedIds } } -function save(): void { - store.scheduleSave(() => ensureLoaded().list) -} - export function getBlockedTracks(): BlockedTrack[] { return [...ensureLoaded().list] } @@ -53,17 +56,12 @@ export function markBlocked<T extends { videoId: string }>(tracks: T[]): Blockab return tracks.map((track) => ({ ...track, isBlocked: isBlocked(track.videoId) })) } -// a blocked track changes the queue (it is skipped), the playlist (it is not played) and the list of blocked tracks -function changed(): void { - emit('blocklist') - emit('state') - emit('fallback') -} - export function blockTrack(videoId: string, title: string): BlockedTrack { const { list, ids } = ensureLoaded() - if (!ids.has(videoId)) { + changes.update(() => { + if (ids.has(videoId)) return + const safeTitle = title.trim().slice(0, TITLE_MAX_LENGTH) || videoId list.unshift({ videoId, title: safeTitle, blockedAt: Date.now() }) ids.add(videoId) @@ -73,24 +71,22 @@ export function blockTrack(videoId: string, title: string): BlockedTrack { for (const removed of dropped) ids.delete(removed.videoId) log.warn(`Limit of ${BLOCKLIST_LIMIT} reached, ${dropped.length} oldest block(s) dropped`) } + }) - save() - changed() - } return list.find((t) => t.videoId === videoId)! } export function unblockTrack(videoId: string): boolean { - const { list, ids } = ensureLoaded() - const before = list.length - blocked = list.filter((t) => t.videoId !== videoId) - ids.delete(videoId) - - if (blocked.length !== before) { - save() - changed() - return true - } + let removed = false + + changes.update(() => { + const { list, ids } = ensureLoaded() + if (!ids.has(videoId)) return + + blocked = list.filter((t) => t.videoId !== videoId) + ids.delete(videoId) + removed = true + }) - return false + return removed } diff --git a/src/config-helper.ts b/src/config-helper.ts index deb85fa..3d9427e 100644 --- a/src/config-helper.ts +++ b/src/config-helper.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from 'node:util' import { createFileStore, deepMerge, type DeepPartial } from './persist.js' -import { createLogger } from './logger.js' +import { createLogger, describeError } from './logger.js' +import { emit, type Topic } from './state-events.js' const FIELD = Symbol('field-rule') @@ -71,10 +72,13 @@ export function createConfigModule<T extends object>(options: { defaults: T schema: Schema refine?: (clean: DeepPartial<T>, current: T, reject: (path: string) => void) => void + // what a saved change announces; the overlay follows the playing state, the panel the config + topics?: readonly Topic[] }) { - const { filePath, defaults, schema, refine } = options + const { filePath, defaults, schema, refine, topics } = options const store = createFileStore<T>(filePath) const log = createLogger('CONFIG') + const changeListeners = new Set<(previous: T, next: T) => void>() // Read from disk on first use, not when the module is imported. A file edited by hand (or left by an older version) // may hold what the schema would refuse: those values are ignored and the defaults stay in their place @@ -102,13 +106,39 @@ export function createConfigModule<T extends object>(options: { const next = deepMerge(previous, clean) const changed = !isDeepStrictEqual(next, previous) config = next - if (changed) save() + if (changed) { + save() + announce(previous, next) + } return { config: structuredClone(next), rejected, changed } } function restoreConfig(snapshot: T): void { + const previous = current() config = structuredClone(snapshot) + if (isDeepStrictEqual(config, previous)) return + save() + announce(previous, config) + } + + function announce(previous: T, next: T): void { + for (const topic of topics ?? []) emit(topic) + for (const listener of [...changeListeners]) { + try { + listener(previous, next) + } catch (error) { + log.error(`A listener of a config change failed: ${describeError(error)}`) + } + } + } + + // The listener gets the config before and after a saved change, so it can react to what actually differs + function onConfigChange(listener: (previous: T, next: T) => void): () => void { + changeListeners.add(listener) + return () => { + changeListeners.delete(listener) + } } return { @@ -116,6 +146,7 @@ export function createConfigModule<T extends object>(options: { validateConfigUpdates, updateConfig, restoreConfig, + onConfigChange, saveNow: async (onError?: (error: unknown) => void) => { await store.saveNow(() => current(), onError) } diff --git a/src/config.ts b/src/config.ts index 4baac7a..189aac6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,16 @@ import { createConfigModule, field, rules } from './config-helper.js' import { dataPath } from './persist.js' import { Config } from './types.js' -import { isValidPlaylistId } from './youtube/url.js' +import { isValidPlaylistId, parsePlaylistId } from './youtube/url.js' const MAX_MIN_VIEWS = 1_000_000_000 const MAX_DURATION_SECONDS = 24 * 60 * 60 const MAX_COUNT = 1000 // `validateConfigUpdates` is exported for tests: the routes validate through `updateConfig` -export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig } = createConfigModule<Config>({ +export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig, onConfigChange } = createConfigModule<Config>({ filePath: () => dataPath('config.json'), + topics: ['config'], defaults: { minViews: 10000, minDurationSeconds: 60, @@ -39,7 +40,14 @@ export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig } = validate: (v) => v === '' || (typeof v === 'string' && /^[A-Z]{2}$/.test(v)) }), fallbackPlaylist: { - playlistId: field({ validate: (v) => v === null || isValidPlaylistId(v) }), + // an id arrives as an id, a URL or an empty string that clears it; anything else is refused like any other field + playlistId: field({ + normalize: (v) => { + if (typeof v !== 'string') return v + return parsePlaylistId(v) ?? (v.trim() ? v : null) + }, + validate: (v) => v === null || isValidPlaylistId(v) + }), enabled: rules.boolean, shuffle: rules.boolean, repeat: rules.boolean diff --git a/src/fallback.ts b/src/fallback.ts index 3e40db9..bbe0607 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -1,9 +1,11 @@ -import { getConfig, updateConfig } from './config.js' +import { isDeepStrictEqual } from 'node:util' +import { getConfig, updateConfig, onConfigChange } from './config.js' import { fetchPlaylistSongs } from './youtube/index.js' import { Song, QueueItem, FallbackPlaylist, FallbackStateResponse, AppError } from './types.js' import { addSong, getCurrent, setCurrent } from './queue.js' -import { emit } from './state-events.js' +import { on, emit } from './state-events.js' import { isBlocked, markBlocked } from './blocklist.js' +import { createStore } from './store.js' import { createLogger } from './logger.js' const log = createLogger('FALLBACK') @@ -28,6 +30,18 @@ let lastFallbackRefreshAt: number | null = null // is rewritten only then and not on every change of position let sourceVersion = 0 +// the rotation is part of the playing state: state-file.ts saves it when it changes +const rotation = createStore({ + read: () => ({ + tracks: sourceVersion, + order: fallbackOrder, + cursor: fallbackCursor, + playlistId: loadedFallbackPlaylistId, + lastRefreshedAt: lastFallbackRefreshAt + }), + topics: ['state'] +}) + function setSourceTracks(tracks: Song[]): void { fallbackTracksById = new Map(tracks.map((track) => [track.videoId, track])) sourceVersion++ @@ -105,22 +119,98 @@ export function peekNextFallbackTrack(): QueueItem | null { export function advanceFallback(): QueueItem | null { const next = findNext() if (!next) return null - fallbackCursor = next.index + + rotation.update(() => { + fallbackCursor = next.index + }) + return toFallbackQueueItem(next.song) } -// the rotation decides what plays next (state) and what the playlist view shows (fallback) -function changed(): void { - emit('state') +// the playlist view the panel reads: the answer of getFallbackState() without the blocked marks (those follow the blocklist) +function fallbackView() { + const { enabled, shuffle, repeat } = getConfig().fallbackPlaylist + const current = getCurrent() + + return { + playlistId: loadedFallbackPlaylistId, + lastRefreshedAt: lastFallbackRefreshAt, + enabled, + shuffle, + repeat, + sourceCount: fallbackTracksById.size, + activeVideoId: current?.isFallback ? current.videoId : null, + order: [...fallbackOrder], + blocked: fallbackOrder.map((videoId) => isBlocked(videoId)) + } +} + +let announcedView: ReturnType<typeof fallbackView> | null = null + +// the 'fallback' announcement is derived, not reported: the view is compared, so a change that does not +// reach it (a viewer track replacing another, a step of the rotation) does not wake the playlist tab +function announceFallbackView(): void { + const view = fallbackView() + if (announcedView === null) { + announcedView = view + return + } + if (isDeepStrictEqual(view, announcedView)) return + announcedView = view emit('fallback') } +on('state', announceFallbackView) +on('config', announceFallbackView) + +let pendingReaction: Promise<void> | null = null + +/** + * Resolves when the fallback reaction the last config save started has settled, so a route can answer with its + * outcome. Nobody has to wait: without a caller the reaction runs in the background and the rollback still happens + */ +export function settledConfigReaction(): Promise<void> { + const reaction = pendingReaction + pendingReaction = null + return reaction ?? Promise.resolve() +} + +function loadPlaylist(playlistId: string | null, rollbackTo: string | null): void { + const load = refreshFallback().then( + () => {}, + (error) => { + // the playlist was replaced while it was loading: the failure is stale, the newer save wins + if (getConfig().fallbackPlaylist.playlistId !== playlistId) return + log.error(`Playlist ${playlistId ?? 'none'} could not be loaded, ${rollbackTo ?? 'no playlist'} is kept instead`) + updateConfig({ fallbackPlaylist: { playlistId: rollbackTo } }) + throw error + } + ) + // a caller that awaits takes the failure; without one the rollback has already run and only the answer is dropped + load.catch(() => {}) + pendingReaction = load +} + +onConfigChange((previous, next) => { + const before = previous.fallbackPlaylist + const after = next.fallbackPlaylist + + if (after.playlistId !== before.playlistId) { + // a rollback restores the id the rotation already holds: nothing to load + if (after.playlistId !== loadedFallbackPlaylistId) loadPlaylist(after.playlistId, before.playlistId) + } else if (after.shuffle !== before.shuffle) { + reorderFallback(after.shuffle) + } + + // what plays next depends on these, although the rotation itself does not change + if (after.enabled !== before.enabled || after.repeat !== before.repeat) rotation.announce() +}) + export async function refreshFallback(): Promise<FallbackStateResponse> { const playlistId = getConfig().fallbackPlaylist.playlistId if (!playlistId) { - resetRotation() - changed() + rotation.update(() => resetRotation()) return getFallbackState() } @@ -135,34 +225,28 @@ export async function refreshFallback(): Promise<FallbackStateResponse> { } const playingId = getCurrent()?.isFallback ? getCurrent()!.videoId : activeFallbackVideoId() - setSourceTracks(newTracks) - fallbackOrder = buildOrder(newTracks, config.fallbackPlaylist.shuffle, playingId) - fallbackCursor = playingId ? fallbackOrder.indexOf(playingId) : -1 - loadedFallbackPlaylistId = playlistId - lastFallbackRefreshAt = Date.now() + + rotation.update(() => { + setSourceTracks(newTracks) + fallbackOrder = buildOrder(newTracks, config.fallbackPlaylist.shuffle, playingId) + fallbackCursor = playingId ? fallbackOrder.indexOf(playingId) : -1 + loadedFallbackPlaylistId = playlistId + lastFallbackRefreshAt = Date.now() + }) log.log(`Loaded ${fallbackTracksById.size} tracks from playlist ${playlistId}`) - changed() return getFallbackState() } -// The playlist id is already stored; if it cannot be loaded, only that id goes back to what it was -export async function refreshFallbackOrRollback(previousPlaylistId: string | null): Promise<void> { - try { - await refreshFallback() - } catch (error) { - updateConfig({ fallbackPlaylist: { playlistId: previousPlaylistId } }) - throw error - } -} - /** Re-orders the rotation for the given shuffle mode, keeping the playing track in place. Does not touch the config. */ export function reorderFallback(shuffleOn: boolean): void { const current = getCurrent() const activeId = current?.isFallback ? current.videoId : activeFallbackVideoId() - fallbackOrder = buildOrder([...fallbackTracksById.values()], shuffleOn, activeId) - fallbackCursor = activeId ? fallbackOrder.indexOf(activeId) : -1 - changed() + + rotation.update(() => { + fallbackOrder = buildOrder([...fallbackTracksById.values()], shuffleOn, activeId) + fallbackCursor = activeId ? fallbackOrder.indexOf(activeId) : -1 + }) } type FallbackFlag = 'shuffle' | 'repeat' | 'enabled' @@ -172,8 +256,6 @@ function toggleFallbackFlag(flag: FallbackFlag): FallbackStateResponse { const patch: Partial<Pick<FallbackPlaylist, FallbackFlag>> = { [flag]: next } updateConfig({ fallbackPlaylist: patch }) - if (flag === 'shuffle') reorderFallback(next) - else changed() log.log(`${flag}: ${next}`) return getFallbackState() @@ -184,11 +266,9 @@ export const toggleFallbackRepeat = (): FallbackStateResponse => toggleFallbackF export const toggleFallbackEnabled = (): FallbackStateResponse => toggleFallbackFlag('enabled') export function clearFallback(): FallbackStateResponse { - resetRotation() + rotation.update(() => resetRotation()) updateConfig({ fallbackPlaylist: { playlistId: null } }) - changed() - emit('playlists') log.log('cleared') return getFallbackState() } @@ -224,7 +304,9 @@ export function playFallbackTrackNow(videoId: string): QueueItem | null { throw new AppError('BLOCKED') } - fallbackCursor = index + rotation.update(() => { + fallbackCursor = index + }) const item = toFallbackQueueItem(song) setCurrent(item) log.log(`Play now: "${song.title}"`) @@ -276,4 +358,7 @@ export function hydrateFallback(data: Partial<FallbackSnapshot> | undefined): vo fallbackCursor = repaired ? -1 : data.cursor ?? -1 loadedFallbackPlaylistId = data.playlistId ?? null lastFallbackRefreshAt = data.lastRefreshedAt ?? null + + // the hydrated state is what a panel that connects now would read: the view announcements start from it + announcedView = fallbackView() } diff --git a/src/playlists.ts b/src/playlists.ts index b4cbd4b..83a6d7f 100644 --- a/src/playlists.ts +++ b/src/playlists.ts @@ -1,4 +1,6 @@ import { dataPath, createFileStore, loadList } from './persist.js' +import { createStore } from './store.js' +import { onConfigChange } from './config.js' import { emit } from './state-events.js' export type SavedPlaylist = { @@ -32,39 +34,48 @@ function isSavedPlaylist(value: unknown): value is SavedPlaylist { let loaded: SavedPlaylist[] | null = null const current = (): SavedPlaylist[] => (loaded ??= loadList(store, isSavedPlaylist)) -function save(): void { - emit('playlists') - store.scheduleSave(() => current()) -} +const changes = createStore({ + read: current, + topics: ['playlists'], + save: () => store.scheduleSave(current) +}) + +// the active mark of the saved playlists is derived from the config's playlist id: it moved, the list is read again +onConfigChange((previous, next) => { + if (previous.fallbackPlaylist.playlistId !== next.fallbackPlaylist.playlistId) emit('playlists') +}) export function getPlaylists(): SavedPlaylist[] { return [...current()] } export function upsertPlaylist(meta: { id: string; title: string; thumbnail: string; itemCount: number }): SavedPlaylist { - const existing = current().find((p) => p.id === meta.id) - - if (existing) { - const updated: SavedPlaylist = { ...existing, title: meta.title, thumbnail: meta.thumbnail, itemCount: meta.itemCount } - loaded = current().map((p) => (p.id === meta.id ? updated : p)) - save() - return updated - } - - const created: SavedPlaylist = { ...meta, addedAt: Date.now() } - current().push(created) - save() - return created + let saved!: SavedPlaylist + + changes.update(() => { + const existing = current().find((p) => p.id === meta.id) + + if (existing) { + saved = { ...existing, title: meta.title, thumbnail: meta.thumbnail, itemCount: meta.itemCount } + loaded = current().map((p) => (p.id === meta.id ? saved : p)) + } else { + saved = { ...meta, addedAt: Date.now() } + current().push(saved) + } + }) + + return saved } export function removePlaylist(id: string): boolean { - const before = current().length - loaded = current().filter((p) => p.id !== id) + let removed = false - if (current().length !== before) { - save() - return true - } + changes.update(() => { + if (current().some((p) => p.id === id)) { + loaded = current().filter((p) => p.id !== id) + removed = true + } + }) - return false + return removed } diff --git a/src/queue.ts b/src/queue.ts index 8e8bdfe..0c44cd2 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -4,7 +4,7 @@ import { parseYouTubeUrl } from './youtube/url.js' import { logRejection, logAcceptance, logSkippedWithOpenReward } from './activity.js' import { QueueItem, Song, AppError, AddedSong } from './types.js' import { isBlocked } from './blocklist.js' -import { emit } from './state-events.js' +import { createStore } from './store.js' import { finishItem } from './finish.js' import { createLogger } from './logger.js' @@ -13,6 +13,12 @@ const queue: QueueItem[] = [] let isPaused = false +// the playing state is persisted and announced by state-file.ts when it changes +const playing = createStore({ + read: () => ({ current: currentSong, queue, isPaused }), + topics: ['state'] +}) + const log = createLogger('QUEUE') // Everything below is derived from `queue` on demand, so there is no second copy that could drift apart from it @@ -53,14 +59,15 @@ export function hydrateQueue(data: { current?: QueueItem | null; queue?: QueueIt export function detachChannelPointsRedemptions(): number { let detached = 0 - for (const item of [currentSong, ...queue]) { - if (item?.channelPointsRedemption) { - delete item.channelPointsRedemption - detached++ + playing.update(() => { + for (const item of [currentSong, ...queue]) { + if (item?.channelPointsRedemption) { + delete item.channelPointsRedemption + detached++ + } } - } + }) - if (detached > 0) emit('state') return detached } @@ -73,8 +80,9 @@ export function getCurrent(): QueueItem | null { } export function setPaused(value: boolean): void { - isPaused = value - emit('state') + playing.update(() => { + isPaused = value + }) } export function getIsPaused(): boolean { @@ -136,9 +144,10 @@ export function addSong(song: Song, requestedBy: string, options: AddOptions = { if (started) { setCurrent(item) } else { - queue.push(item) + playing.update(() => { + queue.push(item) + }) log.log(`added "${song.title}" at position ${queue.length}`) - emit('state') } return { item, started } @@ -146,7 +155,6 @@ export function addSong(song: Song, requestedBy: string, options: AddOptions = { export function setCurrent(item: QueueItem | null): void { const previous = currentSong - currentSong = item // still attached = nobody closed it, so the track was skipped or replaced with its reward open if (previous?.channelPointsRedemption && previous !== item) { @@ -158,9 +166,9 @@ export function setCurrent(item: QueueItem | null): void { log.log(item ? `now playing "${item.title}"` : 'nothing is playing') } - // the playlist shows which of its tracks plays and what comes next: both follow the current track - emit('state') - emit('fallback') + playing.update(() => { + currentSong = item + }) } export function removeByVideoId(videoId: string): QueueItem | null { @@ -172,27 +180,31 @@ export function removeAt(index: number): QueueItem | null { return null } - const [item] = queue.splice(index, 1) + let removed: QueueItem | undefined - if (item) { - log.log(`removed "${item.title}" at position ${index + 1}`) - finishItem(item, { code: 'TRACK_REMOVED' }) - } + playing.update(() => { + const [item] = queue.splice(index, 1) + removed = item - emit('state') + if (item) { + log.log(`removed "${item.title}" at position ${index + 1}`) + finishItem(item, { code: 'TRACK_REMOVED' }) + } + }) - return item ?? null + return removed ?? null } export function clearQueue(): QueueItem[] { - const cleared = [...queue] - queue.length = 0 - - log.log(`cleared ${cleared.length} songs`) + let cleared: QueueItem[] = [] - for (const item of cleared) finishItem(item, { code: 'QUEUE_CLEARED' }) + playing.update(() => { + cleared = [...queue] + queue.length = 0 + for (const item of cleared) finishItem(item, { code: 'QUEUE_CLEARED' }) + }) - emit('state') + log.log(`cleared ${cleared.length} songs`) return cleared } diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index ab6d8dd..bbde4d3 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -4,10 +4,9 @@ import { ok } from '../http.js' import { getConfig, updateConfig } from '../config.js' import { fetchPlaylistMeta } from '../youtube/index.js' import { getPlaylists, upsertPlaylist, removePlaylist, type PlaylistEntry } from '../playlists.js' -import { clearFallback, refreshFallbackOrRollback } from '../fallback.js' +import { clearFallback, settledConfigReaction } from '../fallback.js' import { createLogger } from '../logger.js' import { checkPlaylistIdOrUrl, validatePlaylistIdParam } from '../validate.js' -import { emit } from '../state-events.js' const log = createLogger('PLAYLISTS') @@ -68,8 +67,8 @@ router.post('/:id/activate', async (req, res) => { return ok<ConfigResponse>(res, getConfig()) } + // saves; the fallback module loads the playlist and the route answers with the outcome of that load updateConfig({ fallbackPlaylist: { playlistId: id } }) - await refreshFallbackOrRollback(previousId) - emit('playlists') + await settledConfigReaction() ok<ConfigResponse>(res, getConfig()) }) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 76d1040..8ab8b1c 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -1,12 +1,11 @@ import express from 'express' -import { ConfigResponse, SettingsResponse, AppError } from '../types.js' +import { ConfigResponse, SettingsResponse } from '../types.js' import { ok, sendConfigUpdate } from '../http.js' import { isLocalAddress, localOnly } from '../local-only.js' import { getConfig, updateConfig } from '../config.js' import { getSettings, updateSettings } from '../settings.js' import { getPublicSecretsView, SecretsResponse, updateSecrets } from '../secrets.js' -import { checkPlaylistIdOrUrl } from '../validate.js' -import { refreshFallbackOrRollback, reorderFallback } from '../fallback.js' +import { settledConfigReaction } from '../fallback.js' export const router = express.Router() @@ -24,33 +23,12 @@ router.get('/config', (_req, res) => { ok<ConfigResponse>(res, getConfig()) }) +// saves; the fallback module reacts to what changed (a new playlist is loaded, the rotation re-ordered) +// and the route waits for that outcome, so a playlist that cannot be loaded is answered with its error router.put('/config', async (req, res) => { - const body = { ...(req.body ?? {}) } + const { config: updated, rejected } = updateConfig(req.body ?? {}) - if (body.fallbackPlaylist !== undefined) { - if (typeof body.fallbackPlaylist !== 'object' || body.fallbackPlaylist === null || Array.isArray(body.fallbackPlaylist)) { - throw new AppError('INVALID_CONFIG', undefined, { fields: 'fallbackPlaylist' }) - } - - if (typeof body.fallbackPlaylist.playlistId === 'string') { - const rawPlaylistId = body.fallbackPlaylist.playlistId.trim() - - if (rawPlaylistId) { - body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: checkPlaylistIdOrUrl(rawPlaylistId) } - } else { - body.fallbackPlaylist = { ...body.fallbackPlaylist, playlistId: null } - } - } - } - - const previous = getConfig() - const { config: updated, rejected } = updateConfig(body) - - if (updated.fallbackPlaylist.playlistId !== previous.fallbackPlaylist.playlistId) { - await refreshFallbackOrRollback(previous.fallbackPlaylist.playlistId) - } else if (updated.fallbackPlaylist.shuffle !== previous.fallbackPlaylist.shuffle) { - reorderFallback(updated.fallbackPlaylist.shuffle) - } + await settledConfigReaction() sendConfigUpdate(res, updated, rejected) }) diff --git a/src/secrets.ts b/src/secrets.ts index c876608..ea05f03 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -1,6 +1,5 @@ import { dataPath, type DeepPartial } from './persist.js' import { createConfigModule, field, type FieldRule, type Schema } from './config-helper.js' -import { emit } from './state-events.js' import { getTwitchClientId } from './env.js' import { AppError, type TwitchAuthorization, type TwitchSecrets, type TwitchTokenData, type TwitchUserInfo } from './types.js' @@ -52,6 +51,8 @@ const TWITCH_SCHEMA: Schema = { const secrets = createConfigModule<Secrets>({ filePath: () => dataPath('secrets.json'), defaults: { youtubeApiKey: '', twitch: { tokenData: null, userInfo: null, connectedAt: null, authorization: null } }, + // the account (or its key) changed: the panel reads the connection again + topics: ['twitch'], schema: { // An empty string is allowed: it clears the key youtubeApiKey: field({ normalize: (v) => (typeof v === 'string' ? v.trim() : v), validate: (v) => typeof v === 'string' }), @@ -79,8 +80,6 @@ export function updateTwitchOAuthState(updates: Partial<TwitchSecrets>, immediat if (Object.keys(updates).length === 0) return apply({ twitch: updates }) - // the account (or its token) changed: the panel reads the connection again - emit('twitch') // Save token data immediately to avoid losing it on process exit if (immediate && updates.tokenData) { diff --git a/src/settings.ts b/src/settings.ts index e631837..64b8614 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,6 +1,5 @@ import { createConfigModule, rules } from './config-helper.js' import { dataPath } from './persist.js' -import { emit } from './state-events.js' import type { Settings } from './types.js' const POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const satisfies readonly Settings['position'][] @@ -21,6 +20,8 @@ const settingsPath = () => dataPath('settings.json') const settings = createConfigModule<Settings>({ filePath: settingsPath, defaults: defaultSettings, + // the overlay reads the settings through the playing state, the panel through the config topic + topics: ['state', 'config'], schema: { showVideo: rules.boolean, hideOverlayInfo: rules.boolean, @@ -36,8 +37,4 @@ export const getSettings = settings.getConfig export const validateSettingsUpdates = settings.validateConfigUpdates /** Applies the valid part of `updates` and names the fields that were refused (invalid or unknown), like every save of the panel. */ -export function updateSettings(updates: Partial<Settings>): { config: Settings; rejected: string[]; changed: boolean } { - const result = settings.updateConfig(updates) - if (result.changed) emit('state') - return result -} +export const updateSettings = settings.updateConfig diff --git a/src/sse.ts b/src/sse.ts index 2074444..d9a4bbc 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -10,7 +10,7 @@ export type EventStreamOptions = { maxClients?: number // a quiet connection is kept alive (proxies, antivirus and OBS drop silent ones) and the page can tell it is still there pingEveryMs?: number - // changes of one topic that follow each other are one event: queue.ts reports several per operation + // changes of one topic that follow each other are one event: a change can still be reported by more than one layer coalesceMs?: number // sent first: how long a browser waits before it reconnects on its own retryMs?: number @@ -32,7 +32,6 @@ function requestedTopics(query: unknown): Set<Topic> { */ export function createEventStreams({ maxClients = 20, pingEveryMs = 25_000, coalesceMs = 50, retryMs = 3000 }: EventStreamOptions = {}) { const clients = new Set<Client>() - const sequence = Object.fromEntries(TOPICS.map((topic) => [topic, 0])) as Record<Topic, number> const timers = new Map<Topic, ReturnType<typeof setTimeout>>() let unsubscribe: (() => void) | null = null @@ -46,7 +45,7 @@ export function createEventStreams({ maxClients = 20, pingEveryMs = 25_000, coal } function broadcast(topic: Topic): void { - const frame = `event: changed\ndata: ${JSON.stringify({ topic, seq: ++sequence[topic] })}\n\n` + const frame = `event: changed\ndata: ${JSON.stringify({ topic })}\n\n` for (const client of [...clients]) { if (client.topics.has(topic)) send(client, frame) diff --git a/src/state-events.ts b/src/state-events.ts index 7cef898..bec76b7 100644 --- a/src/state-events.ts +++ b/src/state-events.ts @@ -1,7 +1,7 @@ import { createLogger, describeError } from './logger.js' // What changed, not how: a topic only says "read it again". The data stays behind the existing GET endpoints -export const TOPICS = ['state', 'activity', 'fallback', 'twitch', 'blocklist', 'playlists'] as const +export const TOPICS = ['state', 'activity', 'fallback', 'twitch', 'blocklist', 'playlists', 'config'] as const export type Topic = (typeof TOPICS)[number] type Listener = () => void diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..3ff0836 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,29 @@ +import { isDeepStrictEqual } from 'node:util' +import { emit, type Topic } from './state-events.js' + +type StoreOptions<T> = { + read: () => T + topics: readonly Topic[] + save?: () => void +} + +// update(fn) is the one way to change what a module keeps: the state is compared before and after, and a +// change saves once and announces the topics once. A mutation that changed nothing stays silent +export function createStore<T>(options: StoreOptions<T>) { + const { read, topics, save } = options + + function update(mutate: () => void): void { + const before = structuredClone(read()) + mutate() + if (isDeepStrictEqual(before, structuredClone(read()))) return + save?.() + for (const topic of topics) emit(topic) + } + + // a change that is not in the state itself but in what depends on it: re-read only, nothing to save + function announce(): void { + for (const topic of topics) emit(topic) + } + + return { update, announce } +} diff --git a/tests/backend/announcements.test.ts b/tests/backend/announcements.test.ts index bf9b801..d0e1748 100644 --- a/tests/backend/announcements.test.ts +++ b/tests/backend/announcements.test.ts @@ -9,6 +9,7 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const queue = await import('../../src/queue.js') const fallback = await import('../../src/fallback.js') const { upsertPlaylist, removePlaylist } = await import('../../src/playlists.js') +const { updateConfig } = await import('../../src/config.js') const { TOPICS, on } = await import('../../src/state-events.js') // what the panel is told after each change, topic by topic: it reads again what it is told about and nothing else @@ -17,38 +18,62 @@ for (const topic of TOPICS) on(topic, () => heard.push(topic)) const announced = () => [...new Set(heard)].sort() const song = (videoId: string) => ({ videoId, title: `Track ${videoId}`, channelTitle: 'Channel', thumbnail: '', duration: 120, views: 100_000, url: `https://youtu.be/${videoId}` }) +const IDS = Array.from({ length: 12 }, (_, i) => `track${String(i).padStart(2, '0')}`) beforeEach(() => { + queue.setCurrent(null) + fallback.hydrateFallback({ sourceTracks: IDS.map(song), order: IDS, cursor: -1, playlistId: 'PLannouncetest1', lastRefreshedAt: null }) + updateConfig({ fallbackPlaylist: { shuffle: false, repeat: false, enabled: true } }) heard.length = 0 }) test('a new current track', async (t) => { - await t.test('announces the state and the playlist: the playlist shows which of its tracks plays and what comes next', () => { - queue.setCurrent({ ...song('a'), requestedBy: 'viewer' }) + await t.test('a viewer track announces the state: the playlist view does not depend on it', () => { + queue.setCurrent({ ...song('viewer000001'), requestedBy: 'viewer' }) + + assert.deepEqual(announced(), ['state']) + }) + + await t.test('a playlist track announces the playlist too: the view marks which of its tracks plays', () => { + queue.setCurrent({ ...song('track01'), requestedBy: 'Playlist', isFallback: true }) assert.deepEqual(announced(), ['fallback', 'state']) }) }) +test('pausing', async (t) => { + await t.test('announces the state', () => { + queue.setPaused(true) + + assert.deepEqual(announced(), ['state']) + }) + + await t.test('pausing to the same value announces nothing: nothing changed', () => { + queue.setPaused(true) + + assert.deepEqual(announced(), []) + }) +}) + test('the switches of the playlist', async (t) => { for (const [name, toggle] of [ ['shuffle', fallback.toggleFallbackShuffle], ['repeat', fallback.toggleFallbackRepeat], ['enabled', fallback.toggleFallbackEnabled] ] as const) { - await t.test(`${name} announces the playlist and the state (what plays next depends on it)`, () => { + await t.test(`${name} announces the config, the playlist and the state (what plays next depends on it)`, () => { toggle() - assert.deepEqual(announced(), ['fallback', 'state']) + assert.deepEqual(announced(), ['config', 'fallback', 'state']) }) } }) test('removing the active playlist from the rotation', async (t) => { - await t.test('announces the playlist, the state and the list of saved playlists, whose active mark moved', () => { + await t.test('announces the playlist and the state: the saved list follows the config, which does not change here', () => { fallback.clearFallback() - assert.deepEqual(announced(), ['fallback', 'playlists', 'state']) + assert.deepEqual(announced(), ['fallback', 'state']) }) }) diff --git a/tests/backend/blocklist.test.ts b/tests/backend/blocklist.test.ts index 387c1a0..8b50cfc 100644 --- a/tests/backend/blocklist.test.ts +++ b/tests/backend/blocklist.test.ts @@ -8,6 +8,9 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const { getBlockedTracks, isBlocked, blockTrack, unblockTrack, markBlocked } = await import('../../src/blocklist.js') const { on } = await import('../../src/state-events.js') +const { hydrateFallback } = await import('../../src/fallback.js') + +const song = (videoId: string) => ({ videoId, title: `Track ${videoId}`, channelTitle: 'Channel', thumbnail: '', duration: 120, views: 100_000, url: `https://youtu.be/${videoId}` }) beforeEach(() => { for (const track of getBlockedTracks()) unblockTrack(track.videoId) @@ -136,12 +139,21 @@ test('what a change of the blocklist announces', async (t) => { heard.length = 0 }) - await t.test('blocking a track announces the list, the queue and the playlist: all three show it', () => { + // the playlist view carries the blocked marks of its rotation, so a track of the rotation changes it + hydrateFallback({ sourceTracks: [song('aaaaaaaaaaa')], order: ['aaaaaaaaaaa'], cursor: -1, playlistId: null, lastRefreshedAt: null }) + + await t.test('blocking a track of the rotation announces the list, the queue and the playlist: all three show it', () => { blockTrack('aaaaaaaaaaa', 'First') assert.deepEqual(heard.sort(), ['blocklist', 'fallback', 'state']) }) + await t.test('blocking a track outside the rotation does not wake the playlist tab: its view did not change', () => { + blockTrack('outside00001', 'Outside') + + assert.deepEqual(heard.sort(), ['blocklist', 'state']) + }) + await t.test('blocking a track that is blocked already announces nothing: nothing changed', () => { blockTrack('aaaaaaaaaaa', 'First') heard.length = 0 diff --git a/tests/backend/fallback.refresh.test.ts b/tests/backend/fallback.refresh.test.ts index 6a6c88f..edf5df6 100644 --- a/tests/backend/fallback.refresh.test.ts +++ b/tests/backend/fallback.refresh.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const fallback = await import('../../src/fallback.js') -const { updateConfig } = await import('../../src/config.js') +const { getConfig, updateConfig } = await import('../../src/config.js') const { updateSecrets } = await import('../../src/secrets.js') const { setCurrent } = await import('../../src/queue.js') @@ -68,7 +68,8 @@ beforeEach(async () => { // first start from a reconciliation against the previous subtest's leftover state instead of a clean load PL = `PLrefreshtest${String(++plCounter).padStart(3, '0')}` updateConfig({ fallbackPlaylist: { playlistId: null, enabled: true, repeat: false, shuffle: false } }) - await fallback.refreshFallback() // actually clears loadedFallbackPlaylistId via the "no playlist" branch + // saving the id already starts the reaction, which resets the rotation through the "no playlist" branch + await fallback.settledConfigReaction() }) // Drives the rotation forward `steps` times exactly the way player.ts does it (advanceFallback() sets the @@ -82,7 +83,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when shuffle is off, the playing track stays in place and the rest follow natural order', async () => { stubPlaylist([A, B, C, D, E, F]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: false } }) - await fallback.refreshFallback() + await fallback.settledConfigReaction() advanceTo(3) setCurrent({ ...song(C), requestedBy: 'Playlist', isFallback: true }) @@ -98,7 +99,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when the playing track is still in the playlist, it stays in place', async () => { stubPlaylist([A, B, C, D]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: false } }) - await fallback.refreshFallback() + await fallback.settledConfigReaction() advanceTo(2) setCurrent({ ...song(B), requestedBy: 'Playlist', isFallback: true }) @@ -113,7 +114,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when shuffle is on, the unplayed remainder is reshuffled around the playing track', async () => { stubPlaylist([A, B, C, D]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: true } }) - await fallback.refreshFallback() + await fallback.settledConfigReaction() advanceTo(2) setCurrent({ ...song(B), requestedBy: 'Playlist', isFallback: true }) @@ -127,3 +128,54 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = assert.deepEqual(order.slice(1).sort(), [A, C, D, E], 'the rest are all present') }) }) + +test('the reaction to a saved playlist id', async (t) => { + await t.test('a load that fails restores the id that was there before', async () => { + const realFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname === 'www.googleapis.com') return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 403 }) + return realFetch(input, init) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + + await assert.rejects(fallback.settledConfigReaction()) + assert.equal(getConfig().fallbackPlaylist.playlistId, null, 'rolled back') + assert.equal(fallback.getFallbackProgress().playlistId, null, 'the rotation keeps what it had') + } finally { + globalThis.fetch = realFetch + } + }) + + await t.test('a load that fails after the playlist was replaced neither rolls back nor throws', async () => { + const other = 'PLreplacedtest1' + const realFetch = globalThis.fetch + // the first playlist answers slowly with a refusal, the one that replaces it answers at once + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname === 'www.googleapis.com' && url.searchParams.get('playlistId') === PL) { + await new Promise((resolve) => setTimeout(resolve, 30)) + return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 403 }) + } + return realFetch(input, init) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + stubPlaylist([A, B]) + + updateConfig({ fallbackPlaylist: { playlistId: other } }) + await fallback.settledConfigReaction() + + // the stale failure of the first load arrives after its playlist was already replaced + await new Promise((resolve) => setTimeout(resolve, 60)) + + assert.equal(getConfig().fallbackPlaylist.playlistId, other, 'the newer save is not rolled back') + assert.deepEqual(fallback.getFallbackProgress().order, [A, B], 'the newer playlist is loaded') + } finally { + globalThis.fetch = realFetch + } + }) +}) diff --git a/tests/backend/fallback.restart.test.ts b/tests/backend/fallback.restart.test.ts index e03c172..d16a46c 100644 --- a/tests/backend/fallback.restart.test.ts +++ b/tests/backend/fallback.restart.test.ts @@ -59,8 +59,11 @@ function writeSavedState({ withTrackFile }: { withTrackFile: boolean }): void { beforeEach(async () => { queue.setCurrent(null) updateConfig({ fallbackPlaylist: { playlistId: null, enabled: true, repeat: true, shuffle: false } }) - await fallback.refreshFallback() // the "no playlist" branch forgets the rotation of the previous test + // saving the id already starts the reaction, which resets the rotation through the "no playlist" branch + await fallback.settledConfigReaction() + stubPlaylist(ids) updateConfig({ fallbackPlaylist: { playlistId: PLAYLIST } }) + await fallback.settledConfigReaction() }) async function restart(options: { withTrackFile: boolean; playlist?: string[] }): Promise<void> { diff --git a/tests/backend/routes/playlists.test.ts b/tests/backend/routes/playlists.test.ts index efe42dc..bffc6a5 100644 --- a/tests/backend/routes/playlists.test.ts +++ b/tests/backend/routes/playlists.test.ts @@ -11,8 +11,18 @@ const express = (await import('express')).default const { router } = await import('../../../src/routes/playlists.js') const { getConfig, updateConfig } = await import('../../../src/config.js') const { upsertPlaylist } = await import('../../../src/playlists.js') +const { hydrateFallback } = await import('../../../src/fallback.js') const { errorHandler } = await import('../../../src/error-handler.js') +const song = (videoId: string) => ({ videoId, title: `Track ${videoId}`, channelTitle: 'Channel', thumbnail: '', duration: 120, views: 100_000, url: `https://youtu.be/${videoId}` }) + +// saving a playlist id starts the reaction that loads it; hydrating the rotation first makes the id one the +// rotation already holds, so the config can be set up without a (key-less, failing) load +const setLoadedPlaylist = (playlistId: string | null) => { + hydrateFallback({ sourceTracks: playlistId ? [song('trk00000000')] : [], order: playlistId ? ['trk00000000'] : [], cursor: -1, playlistId, lastRefreshedAt: null }) + updateConfig({ fallbackPlaylist: { playlistId } }) +} + const app = express() app.use(express.json()) app.use('/api/playlists', router) @@ -85,7 +95,7 @@ test('GET /api/playlists', async (t) => { await t.test('says for every playlist whether it is the active one', async () => { upsertPlaylist({ id: 'PLfirst000000001', title: 'First', thumbnail: '', itemCount: 1 }) upsertPlaylist({ id: 'PLsecond00000001', title: 'Second', thumbnail: '', itemCount: 2 }) - updateConfig({ fallbackPlaylist: { playlistId: 'PLsecond00000001' } }) + setLoadedPlaylist('PLsecond00000001') const playlists = await list() @@ -108,7 +118,7 @@ test('DELETE /api/playlists/:id', async (t) => { }) await t.test('answers with the playlists that are left, with their active marks, and says when it cleared the active one', async () => { - updateConfig({ fallbackPlaylist: { playlistId: 'PLsecond00000001' } }) + setLoadedPlaylist('PLsecond00000001') const response = await fetch(`${base}/PLsecond00000001`, { method: 'DELETE' }) const body = (await response.json()) as Record<string, any> diff --git a/tests/backend/routes/settings.test.ts b/tests/backend/routes/settings.test.ts index 87a1bbb..57be3c2 100644 --- a/tests/backend/routes/settings.test.ts +++ b/tests/backend/routes/settings.test.ts @@ -107,12 +107,14 @@ test('PUT /api/config and the fallback playlist', async (t) => { assert.equal((await getConfig()).fallbackPlaylist.shuffle, false) }) - await t.test('a malformed playlist id is a 400 INVALID_PLAYLIST_ID', async () => { + await t.test('a malformed playlist id is refused as a field, like every other invalid value', async () => { const response = await putConfig({ fallbackPlaylist: { playlistId: 'not a playlist' } }) const body = (await response.json()) as Record<string, any> assert.equal(response.status, 400) - assert.equal(body.code, 'INVALID_PLAYLIST_ID') + assert.equal(body.code, 'INVALID_CONFIG') + assert.equal(body.params.fields, 'fallbackPlaylist.playlistId') + assert.equal((await getConfig()).fallbackPlaylist.playlistId, null) }) await t.test('an empty playlist id clears it', async () => { diff --git a/tests/backend/sse.test.ts b/tests/backend/sse.test.ts index af4ad1e..b828748 100644 --- a/tests/backend/sse.test.ts +++ b/tests/backend/sse.test.ts @@ -82,7 +82,7 @@ test('GET /api/events', async (t) => { } }) - await t.test('a change arrives as a frame with its topic and a counter that grows per topic', async () => { + await t.test('a change arrives as a frame that names its topic', async () => { const server = await startServer({ coalesceMs: 5 }) try { const stream = await server.open() @@ -96,10 +96,8 @@ test('GET /api/events', async (t) => { await until(() => frames(stream, 'changed').length === 3, 'a change of another topic') const data = frames(stream, 'changed').map((frame) => JSON.parse(frame.split('data: ')[1])) - const [first, second, third] = data + const [first, , third] = data assert.equal(first.topic, 'state') - assert.equal(second.topic, 'state') - assert.equal(second.seq, first.seq + 1) assert.equal(third.topic, 'activity') } finally { await server.stop() From 53d3504261bb92cf99f69821767aa94171520652 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:30:56 +0700 Subject: [PATCH 10/22] Harden playback state and centralize store persistence Playback: - assign unique play IDs to prevent stale player reports from affecting later plays of the same track - ensure repeated tracks reload correctly State management: - move persistence into the stores responsible for playback and fallback state - fix fallback playlist reactions and rollback handling - publish Twitch and config changes through state topics Logging: - refine application error logging Testing: - expand backend and frontend coverage for playback, persistence, fallback handling, and state updates --- public/js/control/fallback.js | 21 ++++-- public/js/control/index.js | 3 +- public/js/control/queue.js | 2 + public/js/control/tabs.js | 4 ++ public/js/overlay.js | 19 ++++-- scripts/smoke-frontend.mjs | 59 +++++++++------- src/error-handler.ts | 5 +- src/fallback.ts | 46 ++++++++----- src/integrations/twitch/index.ts | 7 +- src/integrations/twitch/oauth.ts | 5 +- src/integrations/twitch/socket.ts | 10 +-- src/integrations/twitch/types.ts | 2 - src/logger.ts | 4 +- src/player.ts | 16 +++-- src/queue.ts | 15 ++++- src/routes/player.ts | 12 +++- src/routes/playlists.ts | 2 +- src/routes/settings.ts | 5 +- src/state-file.ts | 11 ++- src/types.ts | 2 + src/youtube/index.ts | 3 +- tests/backend/announcements.test.ts | 15 ++++- tests/backend/error-handler.test.ts | 18 +++++ tests/backend/fallback.refresh.test.ts | 67 +++++++++++++++++-- tests/backend/fallback.restart.test.ts | 4 +- .../backend/integrations/twitch/oauth.test.ts | 14 ++++ .../integrations/twitch/socket.test.ts | 10 +-- tests/backend/routes/player.test.ts | 15 +++++ tests/backend/routes/settings.test.ts | 32 ++++++++- tests/backend/state-file.import.test.ts | 5 ++ tests/backend/state-file.test.ts | 26 ++++++- tests/frontend/control/wiring.test.js | 49 +++++++++++++- tests/frontend/overlay.test.js | 22 ++++++ 33 files changed, 420 insertions(+), 110 deletions(-) diff --git a/public/js/control/fallback.js b/public/js/control/fallback.js index 1986d6a..2b6dfb8 100644 --- a/public/js/control/fallback.js +++ b/public/js/control/fallback.js @@ -22,7 +22,6 @@ export function refreshFallbackState(silent = false) { export function renderFallback() { const data = state.fallback const tracks = data?.upNext ?? [] - const activeVideoId = data?.activeVideoId ?? '' toggleActive(dom.fallbackShuffleBtn, data?.shuffle) toggleActive(dom.fallbackRepeatBtn, data?.repeat) @@ -43,13 +42,27 @@ export function renderFallback() { datetime: formatDateTime(data.lastRefreshedAt) }) ) - views.fallback.render(tracks.map((track) => ({ ...track, isActive: track.videoId === activeVideoId }))) - scrollToActiveFallback() + renderFallbackTracks() renderStats() } +let renderedActiveVideoId + +// the active row follows the playing state, so a step of the rotation moves the highlight without reading the playlist +export function renderFallbackTracks() { + if (!state.fallback) return + + const activeVideoId = state.current?.isFallback ? state.current.videoId : '' + views.fallback.render((state.fallback.upNext ?? []).map((track) => ({ ...track, isActive: track.videoId === activeVideoId }))) + + if (activeVideoId !== renderedActiveVideoId) { + renderedActiveVideoId = activeVideoId + scrollToActiveFallback() + } +} + export function scrollToActiveFallback() { - const activeVideoId = state.fallback?.activeVideoId + const activeVideoId = state.current?.isFallback ? state.current.videoId : null const container = dom.fallbackListWrapper const list = container?.querySelector('.row-list') if (!activeVideoId || !container || !list) return diff --git a/public/js/control/index.js b/public/js/control/index.js index 29487ac..0efe311 100644 --- a/public/js/control/index.js +++ b/public/js/control/index.js @@ -13,7 +13,7 @@ import { loadBlocklist } from './blocklist.js' import { loadPlaylists } from './playlists.js' import { loadSecrets, loadConfig, loadOverlaySettings, loadNetworkInfo } from './settings.js' import { loadTwitchSettings, loadTwitchConfig, loadTwitchSecrets, refreshTwitchStatus } from './twitch/index.js' -import { isDashboardActive, isPlaylistsActive } from './tabs.js' +import { isDashboardActive, isPlaylistsActive, isSettingsActive } from './tabs.js' import { showNotice } from './notices.js' import { setError } from '../shared.js' @@ -24,6 +24,7 @@ const TOPICS = { fallback: { read: () => refreshFallbackState(true), isShown: isDashboardActive }, blocklist: { read: () => loadBlocklist(true), isShown: isDashboardActive }, playlists: { read: () => loadPlaylists(true), isShown: isPlaylistsActive }, + config: { read: () => loadConfig(), isShown: isSettingsActive }, twitch: { read: () => refreshTwitchStatus(), isShown: () => document.visibilityState === 'visible' } } diff --git a/public/js/control/queue.js b/public/js/control/queue.js index 9c1bed8..2ed90d6 100644 --- a/public/js/control/queue.js +++ b/public/js/control/queue.js @@ -5,6 +5,7 @@ import { renderStats } from './stats.js' import { views } from './views/index.js' import { run } from './run.js' import { renderCurrent, renderNext, renderPlayPause, syncPlayer } from './player.js' +import { renderFallbackTracks } from './fallback.js' import { t } from '../i18n.js' import { toastSuccess } from './toast.js' import { setText } from '../shared.js' @@ -35,6 +36,7 @@ export function renderState() { renderNext() renderQueue() renderPlayPause() + renderFallbackTracks() renderStats() syncPlayer() } diff --git a/public/js/control/tabs.js b/public/js/control/tabs.js index cd78ddb..97b562a 100644 --- a/public/js/control/tabs.js +++ b/public/js/control/tabs.js @@ -3,6 +3,7 @@ import { refreshFallbackState, scrollToActiveFallback } from './fallback.js' import { loadActivity } from './activity.js' import { loadBlocklist } from './blocklist.js' import { loadPlaylists } from './playlists.js' +import { loadConfig } from './settings.js' import { setClass, show } from '../shared.js' export let activeTab = 'dashboard' @@ -10,6 +11,7 @@ let activeSection = 'queue' export const isDashboardActive = () => activeTab === 'dashboard' export const isPlaylistsActive = () => activeTab === 'playlists' +export const isSettingsActive = () => activeTab === 'settings' export function switchPageTab(tabName) { const wasDashboard = activeTab === 'dashboard' @@ -33,6 +35,8 @@ export function switchPageTab(tabName) { // the playlists were not read while their page was hidden if (tabName === 'playlists') loadPlaylists() + // and neither was the config + if (tabName === 'settings') loadConfig() } export function switchSection(sectionName) { diff --git a/public/js/overlay.js b/public/js/overlay.js index 2d0669b..d851560 100644 --- a/public/js/overlay.js +++ b/public/js/overlay.js @@ -8,6 +8,8 @@ let player = null let currentState = null let isPlayerReady = false let isTransitioning = false +// the play the player was last loaded for: a new value means the same track is played again +let loadedPlayId = null // set while a broken player is being replaced and its failure is reported: what the player says meanwhile is not news let resetting = null let settings = {} @@ -97,15 +99,17 @@ function renderState() { if (!currentState.current) { temporaryErrorRetries.clear() + loadedPlayId = null player.stopVideo() return } const videoId = currentState.current.videoId + const playId = currentState.current.playId ?? null const currentVideoId = player.getVideoData()?.video_id const playerState = player.getPlayerState() - if (currentVideoId !== videoId) { + if (currentVideoId !== videoId || playId !== loadedPlayId) { temporaryErrorRetries.clear() if (currentState.isPaused) { @@ -116,6 +120,7 @@ function renderState() { player.loadVideoById(videoId) } + loadedPlayId = playId configurePlayer() return } @@ -198,13 +203,13 @@ async function postWithRetry(url, body) { } } -async function notifyEnded(videoId) { - await postWithRetry('/api/player/ended', { videoId }) +async function notifyEnded(videoId, playId) { + await postWithRetry('/api/player/ended', { videoId, ...(playId != null ? { playId } : {}) }) await readState() } -async function reportFailure(errorCode, videoId) { - if (await postWithRetry('/api/player/report-failure', { errorCode, videoId })) { +async function reportFailure(errorCode, videoId, playId) { + if (await postWithRetry('/api/player/report-failure', { errorCode, videoId, ...(playId != null ? { playId } : {}) })) { log(`Playback failure reported for ${videoId}`) } @@ -254,7 +259,7 @@ function onPlayerStateChange(event) { isTransitioning = true // the track that actually finished in the player, which may differ from what the server considers current now const endedVideoId = event.target.getVideoData?.().video_id || currentState?.current?.videoId - notifyEnded(endedVideoId).finally(() => { + notifyEnded(endedVideoId, loadedPlayId).finally(() => { isTransitioning = false }) } @@ -278,7 +283,7 @@ function onPlayerError(event) { if (retryTemporaryError(event.data, videoId, event.target)) return resetting = resetPlayer() - .then(() => reportFailure(event.data, videoId)) + .then(() => reportFailure(event.data, videoId, loadedPlayId)) .finally(() => { resetting = null }) diff --git a/scripts/smoke-frontend.mjs b/scripts/smoke-frontend.mjs index f6f5f83..8c1bb94 100644 --- a/scripts/smoke-frontend.mjs +++ b/scripts/smoke-frontend.mjs @@ -27,6 +27,7 @@ const track = (c, extra = {}) => ({ duration: 100, views: 5, requestedBy: 'bob', + isFallback: false, ...extra }) let stateOverride = null @@ -68,6 +69,18 @@ const routes = { const state = stateOverride ?? { current: track('a'), queue: [track('b'), track('c')], isPaused: false, nextTrack: null } return { ...state, queue: withBlocked(state.queue) } }, + 'GET /api/fallback': () => { + const current = stateOverride?.current ?? null + return { + upNext: withBlocked([track('e'), track('f')]), + activeVideoId: current?.isFallback ? current.videoId : V('f'), + lastRefreshedAt: Date.now(), + sourceCount: 2, + shuffle: false, + repeat: true, + enabled: true + } + }, 'GET /api/fallback': () => ({ upNext: withBlocked([track('e'), track('f')]), activeVideoId: V('f'), @@ -119,20 +132,20 @@ const $$ = (sel) => [...document.querySelectorAll(sel)] const toasts = () => $$('#toastContainer .toast-message').map((n) => n.textContent) const tick = () => new Promise((r) => setTimeout(r, 50)) let passed = 0 -const check = (name, fn) => { - fn() +const check = async (name, fn) => { + await fn() passed++ process.stdout.write(` ok - ${name}\n`) } // --- initial render -check('queue rows rendered with blocked pill on the blocked one', () => { +await check('queue rows rendered with blocked pill on the blocked one', () => { const rows = $$('#queueListWrapper .row-item') assert.equal(rows.length, 2) assert.equal(rows[0].querySelector('.status-pill'), null) assert.ok(rows[1].querySelector('.status-pill')) }) -check('queue rows: a normal row has a menu (block, remove), a blocked row a dedicated unblock button', () => { +await check('queue rows: a normal row has a menu (block, remove), a blocked row a dedicated unblock button', () => { const rows = $$('#queueListWrapper .row-item') assert.deepEqual( [...rows[0].querySelectorAll('.row-menu-item')].map((b) => b.dataset.action), @@ -141,22 +154,20 @@ check('queue rows: a normal row has a menu (block, remove), a blocked row a dedi assert.equal(rows[1].querySelector('.row-menu'), null) assert.equal(rows[1].querySelector('[data-action="unblock-track"]')?.dataset.videoId, V('c')) }) -check('menu items have role=menuitem, toggles have aria-expanded=false', () => { +await check('menu items have role=menuitem, toggles have aria-expanded=false', () => { assert.ok($$('.row-menu-item').every((b) => b.getAttribute('role') === 'menuitem')) assert.ok($$('[data-action="toggle-menu"]').every((b) => b.getAttribute('aria-expanded') === 'false')) }) -check('fallback: active row highlighted (no dataset side-channel)', () => { +await check('fallback: rows rendered', () => { const rows = $$('#fallbackListWrapper .row-item') assert.equal(rows.length, 2) - assert.ok(!rows[0].classList.contains('row-active')) - assert.ok(rows[1].classList.contains('row-active')) assert.equal(rows[1].dataset.videoId, V('f')) }) -check('playlists: rows rendered', () => { +await check('playlists: rows rendered', () => { const rows = $$('#playlistsListWrapper .row-item') assert.equal(rows.length, 2) }) -check('activity: escaping, thumbnail helper, translated failure reason', () => { +await check('activity: escaping, thumbnail helper, translated failure reason', () => { const rows = $$('#activityListWrapper .row-item') assert.equal(rows.length, 2) assert.ok(rows[0].querySelector('img').src.endsWith(`/vi/${V('a')}/mqdefault.jpg`)) @@ -165,7 +176,7 @@ check('activity: escaping, thumbnail helper, translated failure reason', () => { const item = rows[0].querySelector('[data-action="block-track"]') assert.equal(item.dataset.title, 'A $$ "quoted" & <b>') }) -check('blocklist rendered with thumbnail helper', () => { +await check('blocklist rendered with thumbnail helper', () => { const rows = $$('#blocklistListWrapper .row-item') assert.equal(rows.length, 1) assert.ok(rows[0].querySelector('img').src.includes(`/vi/${V('c')}/`)) @@ -173,20 +184,20 @@ check('blocklist rendered with thumbnail helper', () => { // --- menu behaviour $('#queueListWrapper [data-action="toggle-menu"]').click() -check('toggle-menu opens the dropdown and sets aria-expanded', () => { +await check('toggle-menu opens the dropdown and sets aria-expanded', () => { const menu = $('#queueListWrapper .row-menu') assert.ok(!menu.querySelector('.row-menu-dropdown').classList.contains('hidden')) assert.equal(menu.querySelector('[data-action="toggle-menu"]').getAttribute('aria-expanded'), 'true') }) document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) -check('Escape closes the menu and resets aria-expanded', () => { +await check('Escape closes the menu and resets aria-expanded', () => { const menu = $('#queueListWrapper .row-menu') assert.ok(menu.querySelector('.row-menu-dropdown').classList.contains('hidden')) assert.equal(menu.querySelector('[data-action="toggle-menu"]').getAttribute('aria-expanded'), 'false') }) $('#queueListWrapper [data-action="toggle-menu"]').click() document.body.click() -check('click outside closes the menu', () => { +await check('click outside closes the menu', () => { assert.ok($('#queueListWrapper .row-menu-dropdown').classList.contains('hidden')) }) @@ -195,7 +206,7 @@ calls.length = 0 $('#activityListWrapper [data-action="block-track"]').click() await tick() await tick() -check('block-track posts videoId + title exactly, then reloads blocklist and state', () => { +await check('block-track posts videoId + title exactly, then reloads blocklist and state', () => { assert.ok(calls.includes('POST /api/blocklist')) assert.ok(calls.includes('GET /api/blocklist')) assert.ok(calls.includes('GET /api/state')) @@ -209,7 +220,7 @@ calls.length = 0 $('#queueListWrapper [data-action="unblock-track"]').click() await tick() await tick() -check('unblock-track deletes the entry, reloads the lists and the row becomes a normal one', () => { +await check('unblock-track deletes the entry, reloads the lists and the row becomes a normal one', () => { assert.ok(calls.includes(`DELETE /api/blocklist/${V('c')}`)) assert.ok(calls.includes('GET /api/blocklist')) const rows = $$('#queueListWrapper .row-item') @@ -222,16 +233,16 @@ check('unblock-track deletes the entry, reloads the lists and the row becomes a $(`#fallbackListWrapper [data-video-id="${V('e')}"] [data-action="fallback-play"]`).click() await tick() await tick() -check('API error from an action shows a translated toast', () => { +await check('API error from an action shows a translated toast', () => { assert.ok(toasts().includes('This track has been blocked by the streamer'), JSON.stringify(toasts())) }) const before = toasts().length failStateWith500 = true const { refreshState } = await import(pathToFileURL(`${ROOT}/js/control/queue.js`).href) await refreshState(true) -check('silent refresh failure produces no toast', () => assert.equal(toasts().length, before)) +await check('silent refresh failure produces no toast', () => assert.equal(toasts().length, before)) await refreshState(false) -check('non-silent refresh failure produces a toast', () => assert.equal(toasts().length, before + 1)) +await check('non-silent refresh failure produces a toast', () => assert.equal(toasts().length, before + 1)) failStateWith500 = false // --- server unreachable: one toast for several failing loaders, none for silent polling @@ -242,9 +253,9 @@ globalThis.fetch = async (url, options) => { } const beforeNet = toasts().length await refreshState(true) -check('silent network failure produces no toast', () => assert.equal(toasts().length, beforeNet)) +await check('silent network failure produces no toast', () => assert.equal(toasts().length, beforeNet)) await Promise.all([refreshState(false), refreshState(false), refreshState(false)]) -check('several simultaneous network failures show a single toast', () => { +await check('several simultaneous network failures show a single toast', () => { const netToasts = toasts().filter((m) => m === "Can't reach the server") assert.equal(netToasts.length, 1, JSON.stringify(toasts())) }) @@ -255,7 +266,7 @@ const stray = document.createElement('button') stray.dataset.action = 'does-not-exist' document.body.append(stray) stray.click() -check('unknown data-action is logged instead of silently ignored', () => +await check('unknown data-action is logged instead of silently ignored', () => assert.ok(logs.some((l) => l.includes('No handler registered for action "does-not-exist"'))) ) @@ -265,12 +276,12 @@ const n = toasts().length await run('boom', () => { throw new TypeError('x is undefined') }) -check('non-API errors are logged and shown as a generic error toast', () => { +await check('non-API errors are logged and shown as a generic error toast', () => { assert.equal(toasts().length, n + 1) assert.ok(logs.some((l) => l.includes('Error boom:'))) }) -check('no console.error during the whole run', () => assert.deepEqual(errors, [])) +await check('no console.error during the whole run', () => assert.deepEqual(errors, [])) console.log = () => {} process.stdout.write(`\n${passed} checks passed\n`) process.exit(0) diff --git a/src/error-handler.ts b/src/error-handler.ts index 79d07db..81eba7f 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -16,7 +16,10 @@ export function errorHandler(err: HttpError, _req: Request, res: Response, next: } if (err instanceof AppError) { - if (statusForCode(err.code) >= 500) log.error(describeError(err)) + const status = statusForCode(err.code) + // a 503 answers a request the service cannot serve right now (the quota is spent, the API is down): + // the answer tells the client, the line is a note for the operator and not an alarm + if (status >= 500) log[status === 503 ? 'warn' : 'error'](describeError(err)) return failFromAppError(res, err) } if (err.type === 'entity.parse.failed') return fail(res, 'INVALID_JSON') diff --git a/src/fallback.ts b/src/fallback.ts index bbe0607..595720d 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -6,6 +6,7 @@ import { addSong, getCurrent, setCurrent } from './queue.js' import { on, emit } from './state-events.js' import { isBlocked, markBlocked } from './blocklist.js' import { createStore } from './store.js' +import { persistState } from './state-file.js' import { createLogger } from './logger.js' const log = createLogger('FALLBACK') @@ -30,7 +31,7 @@ let lastFallbackRefreshAt: number | null = null // is rewritten only then and not on every change of position let sourceVersion = 0 -// the rotation is part of the playing state: state-file.ts saves it when it changes +// the rotation is part of the playing state: the store saves and announces it when it changes const rotation = createStore({ read: () => ({ tracks: sourceVersion, @@ -39,7 +40,8 @@ const rotation = createStore({ playlistId: loadedFallbackPlaylistId, lastRefreshedAt: lastFallbackRefreshAt }), - topics: ['state'] + topics: ['state'], + save: persistState }) function setSourceTracks(tracks: Song[]): void { @@ -127,10 +129,10 @@ export function advanceFallback(): QueueItem | null { return toFallbackQueueItem(next.song) } -// the playlist view the panel reads: the answer of getFallbackState() without the blocked marks (those follow the blocklist) +// the playlist view the panel reads: the answer of getFallbackState() without the blocked marks (those follow the +// blocklist) and without the playing position (that follows the state the playing store announces) function fallbackView() { const { enabled, shuffle, repeat } = getConfig().fallbackPlaylist - const current = getCurrent() return { playlistId: loadedFallbackPlaylistId, @@ -139,7 +141,6 @@ function fallbackView() { shuffle, repeat, sourceCount: fallbackTracksById.size, - activeVideoId: current?.isFallback ? current.videoId : null, order: [...fallbackOrder], blocked: fallbackOrder.map((videoId) => isBlocked(videoId)) } @@ -163,16 +164,19 @@ function announceFallbackView(): void { on('state', announceFallbackView) on('config', announceFallbackView) -let pendingReaction: Promise<void> | null = null +// true while a failed load rolls the config back: the playlist it restores must not be loaded again, or two +// playlists that both fail would keep replacing each other forever +let rollingBack = false + +const pendingReactions = new Map<string | null, Promise<void>>() /** - * Resolves when the fallback reaction the last config save started has settled, so a route can answer with its - * outcome. Nobody has to wait: without a caller the reaction runs in the background and the rollback still happens + * Resolves when the reaction the last save of `playlistId` started has settled, so a route that changed the playlist + * can answer with its outcome. Nobody has to wait: without a caller the reaction runs in the background and the + * rollback still happens */ -export function settledConfigReaction(): Promise<void> { - const reaction = pendingReaction - pendingReaction = null - return reaction ?? Promise.resolve() +export function settledConfigReaction(playlistId: string | null): Promise<void> { + return pendingReactions.get(playlistId) ?? Promise.resolve() } function loadPlaylist(playlistId: string | null, rollbackTo: string | null): void { @@ -182,13 +186,23 @@ function loadPlaylist(playlistId: string | null, rollbackTo: string | null): voi // the playlist was replaced while it was loading: the failure is stale, the newer save wins if (getConfig().fallbackPlaylist.playlistId !== playlistId) return log.error(`Playlist ${playlistId ?? 'none'} could not be loaded, ${rollbackTo ?? 'no playlist'} is kept instead`) - updateConfig({ fallbackPlaylist: { playlistId: rollbackTo } }) + rollingBack = true + try { + updateConfig({ fallbackPlaylist: { playlistId: rollbackTo } }) + } finally { + rollingBack = false + } throw error } ) // a caller that awaits takes the failure; without one the rollback has already run and only the answer is dropped load.catch(() => {}) - pendingReaction = load + pendingReactions.set(playlistId, load) + load + .finally(() => { + if (pendingReactions.get(playlistId) === load) pendingReactions.delete(playlistId) + }) + .catch(() => {}) } onConfigChange((previous, next) => { @@ -196,8 +210,8 @@ onConfigChange((previous, next) => { const after = next.fallbackPlaylist if (after.playlistId !== before.playlistId) { - // a rollback restores the id the rotation already holds: nothing to load - if (after.playlistId !== loadedFallbackPlaylistId) loadPlaylist(after.playlistId, before.playlistId) + // a rollback restores the id the rotation already holds, and one that was never loaded stays unloaded + if (!rollingBack && after.playlistId !== loadedFallbackPlaylistId) loadPlaylist(after.playlistId, before.playlistId) } else if (after.shuffle !== before.shuffle) { reorderFallback(after.shuffle) } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 0a9e714..3829677 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -26,7 +26,7 @@ import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish. import { AppError, type AddedSong, type FailureReason } from '../../types.js' import type { QueueItem } from '../../types.js' import { createLogger, describeError } from '../../logger.js' -import { emit, notify } from '../../state-events.js' +import { notify } from '../../state-events.js' import { normalizeTwitchError, isNetworkError } from './error.js' import { getSettings } from '../../settings.js' import { t } from '../../i18n.js' @@ -90,8 +90,7 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = scopes: config.scopes, onTokenUpdated: (tokenData) => { updateTwitchOAuthState({ tokenData }, true) - }, - onAuthStateChange: () => emit('twitch') + } }) const twitchClient = new TwitchClient(oauth) client = twitchClient @@ -206,7 +205,6 @@ async function connectEventSub(): Promise<void> { client: twitchClient, onChannelPointsRedemption: (event) => handleChannelPointsRedemption(event, twitchClient) }) - eventSub.onStatusChange = () => emit('twitch') } if (!eventSub.isConnected()) await connectOrRetry(eventSub, 'Twitch EventSub', log) @@ -364,7 +362,6 @@ async function connectChat(): Promise<void> { botLogin: userInfo.login, onMessage: handleChatMessage }) - chat.onStatusChange = () => emit('twitch') } if (!chat.isConnected()) await connectOrRetry(chat, 'Twitch chat', chatLog) diff --git a/src/integrations/twitch/oauth.ts b/src/integrations/twitch/oauth.ts index 398baf0..88ce491 100644 --- a/src/integrations/twitch/oauth.ts +++ b/src/integrations/twitch/oauth.ts @@ -1,4 +1,5 @@ import { createLogger, describeError } from '../../logger.js' +import { emit } from '../../state-events.js' import { AppError } from '../../types.js' import { normalizeTwitchError } from './error.js' import type { @@ -41,7 +42,6 @@ export class TwitchOAuth { private authFailed = false private tokenGeneration = 0 private onTokenUpdated?: (tokenData: TwitchTokenData) => void - private onAuthStateChange?: () => void constructor(config: TwitchOAuthOptions) { if (!config.clientId) { @@ -54,7 +54,6 @@ export class TwitchOAuth { } this.onTokenUpdated = config.onTokenUpdated - this.onAuthStateChange = config.onAuthStateChange } getConfig(): TwitchAuthConfig { @@ -225,7 +224,7 @@ export class TwitchOAuth { if (this.authFailed === failed) return this.authFailed = failed - this.onAuthStateChange?.() + emit('twitch') } private isCurrent(generation: number): boolean { diff --git a/src/integrations/twitch/socket.ts b/src/integrations/twitch/socket.ts index 5fa2a5e..dab5732 100644 --- a/src/integrations/twitch/socket.ts +++ b/src/integrations/twitch/socket.ts @@ -1,4 +1,5 @@ import WebSocket from 'ws' +import { emit } from '../../state-events.js' import { describeError, type createLogger } from '../../logger.js' /** @@ -33,9 +34,6 @@ export abstract class ReconnectingSocket { protected onOpen(_socket: WebSocket): void {} protected onClosed(): void {} - // Called when the connection goes up or down (the panel shows it); it does not carry the state, the listener reads it - onStatusChange: (() => void) | null = null - private reportedConnected = false private starting: Promise<void> | null = null private attempts = 0 @@ -108,11 +106,7 @@ export abstract class ReconnectingSocket { if (connected === this.reportedConnected) return this.reportedConnected = connected - try { - this.onStatusChange?.() - } catch (error) { - this.log.error(`Status listener failed: ${describeError(error)}`) - } + emit('twitch') } protected abort(error: Error): void { diff --git a/src/integrations/twitch/types.ts b/src/integrations/twitch/types.ts index ee0cdb0..a305c21 100644 --- a/src/integrations/twitch/types.ts +++ b/src/integrations/twitch/types.ts @@ -12,8 +12,6 @@ export type TwitchOAuthOptions = { clientId: string scopes?: string[] onTokenUpdated?: (tokenData: TwitchTokenData) => void - // the saved login was refused by Twitch, or works again - onAuthStateChange?: () => void } export type TwitchDeviceCodeResponse = { diff --git a/src/logger.ts b/src/logger.ts index 8a4c98e..0886546 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -25,7 +25,9 @@ function timestamp(): string { export function describeError(error: unknown, withStack = false): string { if (error instanceof AppError) { const params = error.params && Object.keys(error.params).length > 0 ? ` ${JSON.stringify(error.params)}` : '' - const described = `AppError ${error.code}: ${error.message}${params}` + // the default message repeats the code: printing both says the same thing twice + const detail = error.message === error.code ? '' : `: ${error.message}` + const described = `AppError ${error.code}${detail}${params}` return withStack ? `${described}\n${error.stack ?? ''}` : described } diff --git a/src/player.ts b/src/player.ts index 774a4b9..4740e66 100644 --- a/src/player.ts +++ b/src/player.ts @@ -73,13 +73,15 @@ export function playbackFailureReasonCode(errorCode?: number): ActivityReasonCod } } -function isAboutCurrent(videoId?: string): boolean { +function isAboutCurrent(videoId?: string, playId?: number): boolean { + // the play id tells an old play of the same track from the one that runs now + if (playId !== undefined) return getCurrent()?.playId === playId && (videoId === undefined || getCurrent()?.videoId === videoId) return videoId === undefined || getCurrent()?.videoId === videoId } -// The player reports that `videoId` finished. Returns false if that was not the current track and nothing changed -export function endCurrent(videoId?: string): boolean { - if (!isAboutCurrent(videoId)) { +// The player reports that a play of `videoId` finished. Returns false if that was not the current play and nothing changed +export function endCurrent(videoId?: string, playId?: number): boolean { + if (!isAboutCurrent(videoId, playId)) { log.log(`ignored "ended" for ${videoId}: it is not the current track`) return false } @@ -89,9 +91,9 @@ export function endCurrent(videoId?: string): boolean { return true } -// The player reports that `videoId` failed. Returns false if that was not the current track and nothing changed -export function reportPlaybackFailure(errorCode?: number, videoId?: string): boolean { - if (!isAboutCurrent(videoId)) { +// The player reports that a play of `videoId` failed. Returns false if that was not the current play and nothing changed +export function reportPlaybackFailure(errorCode?: number, videoId?: string, playId?: number): boolean { + if (!isAboutCurrent(videoId, playId)) { log.log(`ignored playback failure for ${videoId}: it is not the current track`) return false } diff --git a/src/queue.ts b/src/queue.ts index 0c44cd2..c3d544b 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -6,17 +6,20 @@ import { QueueItem, Song, AppError, AddedSong } from './types.js' import { isBlocked } from './blocklist.js' import { createStore } from './store.js' import { finishItem } from './finish.js' +import { persistState } from './state-file.js' import { createLogger } from './logger.js' let currentSong: QueueItem | null = null const queue: QueueItem[] = [] let isPaused = false +let playCounter = 0 -// the playing state is persisted and announced by state-file.ts when it changes +// the playing state is saved and announced by the store when it changes const playing = createStore({ read: () => ({ current: currentSong, queue, isPaused }), - topics: ['state'] + topics: ['state'], + save: persistState }) const log = createLogger('QUEUE') @@ -38,6 +41,7 @@ function isActiveNonFallback(videoId: string): boolean { export function hydrateQueue(data: { current?: QueueItem | null; queue?: QueueItem[] }): void { if (data.current !== undefined) currentSong = data.current + playCounter = Math.max(playCounter, data.current?.playId ?? 0) if (Array.isArray(data.queue)) { queue.length = 0 @@ -167,7 +171,12 @@ export function setCurrent(item: QueueItem | null): void { } playing.update(() => { - currentSong = item + if (item === null) { + currentSong = null + } else { + item.playId = ++playCounter + currentSong = item + } }) } diff --git a/src/routes/player.ts b/src/routes/player.ts index 7f244c5..c778e05 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -18,10 +18,17 @@ function readVideoId(body: unknown): string | undefined { return videoId } +// `playId` says which play of that track the report is about; a number that is not one is ignored, not refused +function readPlayId(body: unknown): number | undefined { + const playId = (body as { playId?: unknown } | undefined)?.playId + return typeof playId === 'number' && Number.isInteger(playId) && playId > 0 ? playId : undefined +} + router.post('/ended', (req, res) => { const videoId = readVideoId(req.body) + const playId = readPlayId(req.body) - const applied = endCurrent(videoId) + const applied = endCurrent(videoId, playId) ok(res, { ...getState(), ...(applied ? {} : { ignored: true }) }) }) @@ -46,9 +53,10 @@ router.post('/resume', (_req, res) => { router.post('/report-failure', (req, res) => { const videoId = readVideoId(req.body) + const playId = readPlayId(req.body) const errorCode = typeof req.body?.errorCode === 'number' ? req.body.errorCode : undefined - const applied = reportPlaybackFailure(errorCode, videoId) + const applied = reportPlaybackFailure(errorCode, videoId, playId) const state = getState() const locale = getSettings().locale diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index bbde4d3..2be322d 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -69,6 +69,6 @@ router.post('/:id/activate', async (req, res) => { // saves; the fallback module loads the playlist and the route answers with the outcome of that load updateConfig({ fallbackPlaylist: { playlistId: id } }) - await settledConfigReaction() + await settledConfigReaction(id) ok<ConfigResponse>(res, getConfig()) }) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 8ab8b1c..c7432d8 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -26,9 +26,12 @@ router.get('/config', (_req, res) => { // saves; the fallback module reacts to what changed (a new playlist is loaded, the rotation re-ordered) // and the route waits for that outcome, so a playlist that cannot be loaded is answered with its error router.put('/config', async (req, res) => { + const previous = getConfig() const { config: updated, rejected } = updateConfig(req.body ?? {}) - await settledConfigReaction() + if (updated.fallbackPlaylist.playlistId !== previous.fallbackPlaylist.playlistId) { + await settledConfigReaction(updated.fallbackPlaylist.playlistId) + } sendConfigUpdate(res, updated, rejected) }) diff --git a/src/state-file.ts b/src/state-file.ts index d09404d..83d3be9 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -10,7 +10,6 @@ import { FallbackSnapshot, alignFallbackCursor } from './fallback.js' -import { on } from './state-events.js' import { createLogger, describeError } from './logger.js' export type StateFile = { @@ -65,11 +64,14 @@ export function sanitizeQueueItem(raw: unknown): QueueItem | null { // without the redemption a restored track would play, but its Channel Points reward would never be closed const redemption = isObject(raw) ? sanitizeRedemption(raw.channelPointsRedemption) : undefined + // the play id survives a restart, so a report that arrives late still names the play it belongs to + const playId = isObject(raw) && Number.isInteger(raw.playId) && (raw.playId as number) > 0 ? (raw.playId as number) : undefined return { ...song, requestedBy, ...(isObject(raw) && raw.isFallback === true ? { isFallback: true } : {}), + ...(playId !== undefined ? { playId } : {}), ...(redemption ? { channelPointsRedemption: redemption } : {}) } } @@ -162,7 +164,11 @@ function stateSnapshot(): StateFile { // -1 until the track list has been saved (or read from its own file), so an older state file gets migrated let savedTracksVersion = -1 -function persistState(): void { +// The stores that own the playing state save through this hook. Nothing is saved before the state was loaded: +// a change made before would write the not-yet-loaded state over the file +export function persistState(): void { + if (!initialized) return + store.scheduleSave(stateSnapshot) const version = getFallbackSourceVersion() @@ -213,5 +219,4 @@ export function initState(): void { initialized = true loadState() - on('state', persistState) } diff --git a/src/types.ts b/src/types.ts index 8b65a1c..f493c86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,8 @@ export type Song = { export type QueueItem = Song & { requestedBy: string isFallback?: boolean + // set when a track becomes the current one: a new value is a new play, even of the same track + playId?: number // set only when the song was added via a Twitch Channel Points redemption, and removed once the redemption is closed; // carries what finishItem() needs to fulfill or refund the points and notify the requester in chat channelPointsRedemption?: { id: string; rewardId: string; userName: string } diff --git a/src/youtube/index.ts b/src/youtube/index.ts index 41f09b2..85771cc 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -15,7 +15,7 @@ import { normalize } from './utils.js' import { getSearchCache, setSearchCache, getVideoCache, setVideoCache } from './cache.js' import { VideoItem, SearchItem, PlaylistItem } from './types.js' import { getConfig } from '../config.js' -import { createLogger, describeError } from '../logger.js' +import { createLogger } from '../logger.js' const pendingSearches = new Map<string, Promise<VideoItem[]>>() const pendingVideos = new Map<string, Promise<VideoItem | null>>() @@ -29,7 +29,6 @@ async function withYouTubeErrorHandling<T>(label: string, fn: () => Promise<T>): try { return await fn() } catch (error) { - log.error(`${label}: ${describeError(error)}`) if (error instanceof AppError) throw error throw new AppError('YOUTUBE_ERROR', `${label} failed`) } diff --git a/tests/backend/announcements.test.ts b/tests/backend/announcements.test.ts index d0e1748..a2a683a 100644 --- a/tests/backend/announcements.test.ts +++ b/tests/backend/announcements.test.ts @@ -34,10 +34,21 @@ test('a new current track', async (t) => { assert.deepEqual(announced(), ['state']) }) - await t.test('a playlist track announces the playlist too: the view marks which of its tracks plays', () => { + await t.test('a playlist track announces the state: the active row follows it, the playlist is not read again', () => { queue.setCurrent({ ...song('track01'), requestedBy: 'Playlist', isFallback: true }) - assert.deepEqual(announced(), ['fallback', 'state']) + assert.deepEqual(announced(), ['state']) + }) + + await t.test('the same track played again is a new play, announced like one', () => { + queue.setCurrent({ ...song('track01'), requestedBy: 'Playlist', isFallback: true }) + const firstPlayId = queue.getCurrent()?.playId + heard.length = 0 + + queue.setCurrent({ ...song('track01'), requestedBy: 'Playlist', isFallback: true }) + + assert.deepEqual(announced(), ['state'], 'a repeat is a change: the overlay reloads the video') + assert.notEqual(queue.getCurrent()?.playId, firstPlayId) }) }) diff --git a/tests/backend/error-handler.test.ts b/tests/backend/error-handler.test.ts index 4752c64..806776e 100644 --- a/tests/backend/error-handler.test.ts +++ b/tests/backend/error-handler.test.ts @@ -26,6 +26,9 @@ app.get('/twitch', () => { app.get('/twitch-network', () => { throw new AppError('TWITCH_NETWORK_ERROR', 'connect ECONNREFUSED') }) +app.get('/quota', () => { + throw new AppError('YOUTUBE_QUOTA') +}) app.use(errorHandler) const server = app.listen(0) @@ -94,6 +97,20 @@ test('errorHandler', async (t) => { assert.ok(body.error.includes('title must be unique')) }) + await t.test('a 503 refusal is one warning, not an error: a day without quota would fill the log with them', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) + const warnings = t2.mock.method(console, 'warn', () => {}) + + const response = await fetch(`${base}/quota`) + const body = (await response.json()) as Record<string, any> + + assert.equal(response.status, 503) + assert.equal(body.code, 'YOUTUBE_QUOTA') + assert.equal(errors.mock.callCount(), 0) + assert.equal(warnings.mock.callCount(), 1) + assert.ok(String(warnings.mock.calls[0]?.arguments ?? '').includes('YOUTUBE_QUOTA')) + }) + await t.test('a server-side AppError is logged, an expected refusal is not', async (t2) => { const errors = t2.mock.method(console, 'error', () => {}) @@ -118,6 +135,7 @@ test('errorHandler', async (t) => { await t.test('describeError says which error it was, not only its message', () => { assert.equal(describeError(new AppError('USER_LIMIT', 'you can only queue 2 track(s) at a time', { count: 2 })), 'AppError USER_LIMIT: you can only queue 2 track(s) at a time {"count":2}') + assert.equal(describeError(new AppError('DUPLICATE')), 'AppError DUPLICATE') assert.equal(describeError(new TypeError('fetch failed')), 'TypeError: fetch failed') assert.equal(describeError(new Error('a plain one')), 'a plain one') assert.equal(describeError('not even an error'), 'not even an error') diff --git a/tests/backend/fallback.refresh.test.ts b/tests/backend/fallback.refresh.test.ts index edf5df6..f1d47bd 100644 --- a/tests/backend/fallback.refresh.test.ts +++ b/tests/backend/fallback.refresh.test.ts @@ -69,7 +69,7 @@ beforeEach(async () => { PL = `PLrefreshtest${String(++plCounter).padStart(3, '0')}` updateConfig({ fallbackPlaylist: { playlistId: null, enabled: true, repeat: false, shuffle: false } }) // saving the id already starts the reaction, which resets the rotation through the "no playlist" branch - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(null) }) // Drives the rotation forward `steps` times exactly the way player.ts does it (advanceFallback() sets the @@ -83,7 +83,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when shuffle is off, the playing track stays in place and the rest follow natural order', async () => { stubPlaylist([A, B, C, D, E, F]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: false } }) - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(PL) advanceTo(3) setCurrent({ ...song(C), requestedBy: 'Playlist', isFallback: true }) @@ -99,7 +99,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when the playing track is still in the playlist, it stays in place', async () => { stubPlaylist([A, B, C, D]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: false } }) - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(PL) advanceTo(2) setCurrent({ ...song(B), requestedBy: 'Playlist', isFallback: true }) @@ -114,7 +114,7 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await t.test('when shuffle is on, the unplayed remainder is reshuffled around the playing track', async () => { stubPlaylist([A, B, C, D]) updateConfig({ fallbackPlaylist: { playlistId: PL, shuffle: true } }) - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(PL) advanceTo(2) setCurrent({ ...song(B), requestedBy: 'Playlist', isFallback: true }) @@ -141,7 +141,7 @@ test('the reaction to a saved playlist id', async (t) => { try { updateConfig({ fallbackPlaylist: { playlistId: PL } }) - await assert.rejects(fallback.settledConfigReaction()) + await assert.rejects(fallback.settledConfigReaction(PL)) assert.equal(getConfig().fallbackPlaylist.playlistId, null, 'rolled back') assert.equal(fallback.getFallbackProgress().playlistId, null, 'the rotation keeps what it had') } finally { @@ -149,6 +149,61 @@ test('the reaction to a saved playlist id', async (t) => { } }) + await t.test('a rollback to a playlist that was never loaded does not start another load', async () => { + const realFetch = globalThis.fetch + let attempts = 0 + const refusing = async (input: unknown, init?: unknown) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input as never, init as never) + attempts++ + return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 403 }) + } + + try { + stubPlaylist([A]) + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + // a start without a network: the config names a playlist the rotation does not hold + fallback.hydrateFallback({ sourceTracks: [], order: [], cursor: -1, playlistId: null, lastRefreshedAt: null }) + + globalThis.fetch = refusing + const other = 'PLrollbacktest1' + updateConfig({ fallbackPlaylist: { playlistId: other } }) + + await assert.rejects(fallback.settledConfigReaction(other)) + assert.equal(getConfig().fallbackPlaylist.playlistId, PL, 'rolled back to the configured playlist') + + // timers get their turn only when the rollback did not start a load of its own + await new Promise((resolve) => setTimeout(resolve, 60)) + assert.equal(attempts, 1, 'exactly one load was attempted') + assert.equal(getConfig().fallbackPlaylist.playlistId, PL) + } finally { + globalThis.fetch = realFetch + } + }) + + await t.test('the reaction of a playlist can be awaited by more than one caller', async () => { + const realFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname === 'www.googleapis.com') return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 403 }) + return realFetch(input, init) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + + const first = fallback.settledConfigReaction(PL) + const second = fallback.settledConfigReaction(PL) + + await assert.rejects(first) + await assert.rejects(second) + } finally { + globalThis.fetch = realFetch + } + }) + await t.test('a load that fails after the playlist was replaced neither rolls back nor throws', async () => { const other = 'PLreplacedtest1' const realFetch = globalThis.fetch @@ -167,7 +222,7 @@ test('the reaction to a saved playlist id', async (t) => { stubPlaylist([A, B]) updateConfig({ fallbackPlaylist: { playlistId: other } }) - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(other) // the stale failure of the first load arrives after its playlist was already replaced await new Promise((resolve) => setTimeout(resolve, 60)) diff --git a/tests/backend/fallback.restart.test.ts b/tests/backend/fallback.restart.test.ts index d16a46c..f17ad16 100644 --- a/tests/backend/fallback.restart.test.ts +++ b/tests/backend/fallback.restart.test.ts @@ -60,10 +60,10 @@ beforeEach(async () => { queue.setCurrent(null) updateConfig({ fallbackPlaylist: { playlistId: null, enabled: true, repeat: true, shuffle: false } }) // saving the id already starts the reaction, which resets the rotation through the "no playlist" branch - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(null) stubPlaylist(ids) updateConfig({ fallbackPlaylist: { playlistId: PLAYLIST } }) - await fallback.settledConfigReaction() + await fallback.settledConfigReaction(PLAYLIST) }) async function restart(options: { withTrackFile: boolean; playlist?: string[] }): Promise<void> { diff --git a/tests/backend/integrations/twitch/oauth.test.ts b/tests/backend/integrations/twitch/oauth.test.ts index e36ee9d..7c8d875 100644 --- a/tests/backend/integrations/twitch/oauth.test.ts +++ b/tests/backend/integrations/twitch/oauth.test.ts @@ -1,6 +1,7 @@ import { test, beforeEach, after } from 'node:test' import assert from 'node:assert/strict' import { TwitchOAuth } from '../../../../src/integrations/twitch/oauth.js' +import { on } from '../../../../src/state-events.js' const realFetch = globalThis.fetch after(() => { @@ -144,3 +145,16 @@ test('a refresh answer that lands after the account was disconnected', async (t) assert.ok(logged.includes('Token refresh dropped'), logged) }) }) + +test('a change of the auth state announces the twitch topic', async (t) => { + stub(() => json(400, { status: 400, message: 'Invalid refresh token' })) + const oauth = expiredLogin() + const states: boolean[] = [] + const off = on('twitch', () => states.push(oauth.needsReauthorization())) + t.after(off) + + await assert.rejects(oauth.refreshAccessToken()) + oauth.clearTokenData() + + assert.deepEqual(states, [true, false]) +}) diff --git a/tests/backend/integrations/twitch/socket.test.ts b/tests/backend/integrations/twitch/socket.test.ts index 1c03819..89b6b34 100644 --- a/tests/backend/integrations/twitch/socket.test.ts +++ b/tests/backend/integrations/twitch/socket.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import type { AddressInfo } from 'node:net' import { WebSocketServer, type WebSocket as ServerSocket } from 'ws' import { ReconnectingSocket } from '../../../../src/integrations/twitch/socket.js' +import { on } from '../../../../src/state-events.js' const logged: string[] = [] @@ -235,21 +236,22 @@ test('ReconnectingSocket', async (t) => { assert.equal(socket.isConnected(), true) }) - await t.test('onStatusChange is called once when the connection goes up and once when it goes down', async () => { + await t.test('the twitch topic is announced once when the connection goes up and once when it goes down', async (t) => { const { server, socket } = await connected() const states: boolean[] = [] - socket.onStatusChange = () => states.push(socket.isConnected()) + const off = on('twitch', () => states.push(socket.isConnected())) + t.after(off) socket.retryAllowed = false await socket.connect() assert.deepEqual(states, [true]) server.dropClients() - await until(() => states.length === 2, 'the drop to be reported') + await until(() => states.length === 2, 'the drop to be announced') assert.deepEqual(states, [true, false]) await socket.disconnect() - assert.deepEqual(states, [true, false], 'a socket that is already down does not report again') + assert.deepEqual(states, [true, false], 'a socket that is already down does not announce again') }) await t.test('restart() closes the socket and opens a fresh one', async () => { diff --git a/tests/backend/routes/player.test.ts b/tests/backend/routes/player.test.ts index 57d49ea..fbf3211 100644 --- a/tests/backend/routes/player.test.ts +++ b/tests/backend/routes/player.test.ts @@ -79,6 +79,21 @@ test('POST /api/player/ended', async (t) => { assert.equal(currentId(), B) }) + await t.test('an "ended" with the play id of an older play of the same track is ignored', async () => { + const stalePlayId = queue.getCurrent()!.playId + queue.setCurrent({ ...song(A), requestedBy: 'viewer' }) + const currentPlayId = queue.getCurrent()!.playId + assert.notEqual(stalePlayId, currentPlayId) + + const stale = await post('ended', { videoId: A, playId: stalePlayId }) + + assert.equal(((await stale.json()) as Record<string, any>).data.ignored, true) + assert.equal(currentId(), A) + + await post('ended', { videoId: A, playId: currentPlayId }) + assert.equal(currentId(), B) + }) + await t.test('without a videoId it still means the current track (older overlays)', async () => { await post('ended') diff --git a/tests/backend/routes/settings.test.ts b/tests/backend/routes/settings.test.ts index 57be3c2..7727a01 100644 --- a/tests/backend/routes/settings.test.ts +++ b/tests/backend/routes/settings.test.ts @@ -10,7 +10,7 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const express = (await import('express')).default const { router } = await import('../../../src/routes/settings.js') const { errorHandler } = await import('../../../src/error-handler.js') -const { updateTwitchOAuthState } = await import('../../../src/secrets.js') +const { updateTwitchOAuthState, updateSecrets } = await import('../../../src/secrets.js') const app = express() app.use(express.json()) @@ -211,3 +211,33 @@ test('GET /api/secrets and a login that waits for its code', async (t) => { updateTwitchOAuthState({ authorization: null }) }) + +test('PUT /api/config while a playlist is still loading', async (t) => { + updateSecrets({ youtubeApiKey: 'test-key' }) + const realFetch = globalThis.fetch + after(() => { + globalThis.fetch = realFetch + }) + + await t.test('a save that does not touch the playlist is answered at once, and with its own result', async () => { + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname === 'www.googleapis.com') { + await new Promise((resolve) => setTimeout(resolve, 30)) + return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 403 }) + } + return realFetch(input, init) + } + + const playlistSave = putConfig({ fallbackPlaylist: { playlistId: 'PLraceslower01' } }) + await new Promise((resolve) => setTimeout(resolve, 5)) + + const response = await putConfig({ minViews: 777 }) + + assert.equal(response.status, 200) + assert.equal((await getConfig()).minViews, 777) + + assert.equal((await playlistSave).status, 503) + assert.equal((await getConfig()).fallbackPlaylist.playlistId, null, 'the failed playlist save was rolled back') + }) +}) diff --git a/tests/backend/state-file.import.test.ts b/tests/backend/state-file.import.test.ts index 4fc3a00..eb8330c 100644 --- a/tests/backend/state-file.import.test.ts +++ b/tests/backend/state-file.import.test.ts @@ -28,4 +28,9 @@ test('importing the player modules has no side effects', async () => { await flushAllStores() assert.equal(readFileSync(stateFile, 'utf8'), original, 'the state file must not be written until initState() is called') + + queue.setCurrent({ videoId: 'bbbbbbbbbbb', title: 'B', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'viewer' }) + await flushAllStores() + + assert.equal(readFileSync(stateFile, 'utf8'), original, 'a change before the state was loaded is not saved either') }) diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index 89a4d13..e9c30e1 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -222,6 +222,30 @@ test('initState()', async (t) => { [C] ) }) + + await t.test('a change of the overlay settings does not rewrite the queue state', async () => { + const { updateSettings } = await import('../../src/settings.js') + await flushAllStores() + await new Promise((resolve) => setTimeout(resolve, 5)) + const before = statSync(stateFile).mtimeMs + + updateSettings({ opacity: 37 }) + await flushAllStores() + + assert.equal(statSync(stateFile).mtimeMs, before) + }) + + await t.test('a state announcement alone does not rewrite the queue state', async () => { + const { emit } = await import('../../src/state-events.js') + await flushAllStores() + await new Promise((resolve) => setTimeout(resolve, 5)) + const before = statSync(stateFile).mtimeMs + + emit('state') + await flushAllStores() + + assert.equal(statSync(stateFile).mtimeMs, before) + }) }) test('loadState() with a damaged state file', async (t) => { diff --git a/tests/frontend/control/wiring.test.js b/tests/frontend/control/wiring.test.js index 9be249b..a57a1e4 100644 --- a/tests/frontend/control/wiring.test.js +++ b/tests/frontend/control/wiring.test.js @@ -1,6 +1,6 @@ import { test, mock } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, installEventSource, flush, deferred, makeTrack as track } from './panel-env.js' +import { setupPanel, installEventSource, flush, deferred, makeTrack as track, V } from './panel-env.js' import { api } from '../../../public/js/control/api.js' const panel = setupPanel() @@ -42,9 +42,9 @@ async function elapse(ms) { const panelHandle = await startControlPanel() test('the control panel and its events', async (t) => { - await t.test('opens one connection for the six topics', () => { + await t.test('opens one connection for the seven topics', () => { assert.equal(sources.length, 1) - assert.equal(source().url, '/api/events?topics=state,activity,fallback,blocklist,playlists,twitch') + assert.equal(source().url, '/api/events?topics=state,activity,fallback,blocklist,playlists,config,twitch') }) await t.test('everything is read again each time the connection comes up: what changed while it was down was never announced', async () => { @@ -95,6 +95,49 @@ test('the control panel and its events', async (t) => { panel.calls.length = 0 }) + await t.test('the config is read only while its page is shown, and when it is opened', async () => { + panel.calls.length = 0 + source().emit('changed', { topic: 'config' }) + await elapse(100) + assert.deepEqual(panel.calls, [], 'the dashboard is shown: nobody looks at the config') + + switchPageTab('settings') + await flush() + assert.deepEqual(panel.calls, ['GET /api/config'], 'opening the page reads it') + + panel.calls.length = 0 + source().emit('changed', { topic: 'config' }) + await elapse(100) + assert.deepEqual(panel.calls, ['GET /api/config']) + + switchPageTab('dashboard') + await flush() + panel.calls.length = 0 + }) + + await t.test('the active row of the playlist follows the playing state without reading the playlist again', async () => { + panel.route('GET /api/fallback', () => ({ upNext: [track('e'), track('f')], activeVideoId: null, lastRefreshedAt: 1, sourceCount: 2, shuffle: false, repeat: false, enabled: true })) + panel.route('GET /api/state', () => ({ current: track('a'), queue: [], isPaused: false })) + panel.calls.length = 0 + + source().emit('changed', { topic: 'fallback' }) + await elapse(100) + assert.deepEqual(panel.calls, ['GET /api/fallback']) + assert.equal(panel.$(`[data-video-id="${V('e')}"]`)?.classList.contains('row-active'), false) + + panel.route('GET /api/state', () => ({ current: { ...track('f'), requestedBy: 'Playlist', isFallback: true }, queue: [], isPaused: false })) + panel.calls.length = 0 + source().emit('changed', { topic: 'state' }) + await elapse(100) + + assert.deepEqual(panel.calls, ['GET /api/state'], 'a step of the rotation re-reads the state, not the playlist') + assert.equal(panel.$(`[data-video-id="${V('f')}"]`)?.classList.contains('row-active'), true) + assert.equal(panel.$(`[data-video-id="${V('e')}"]`)?.classList.contains('row-active'), false) + + panel.route('GET /api/state', () => ({ current: track('a'), queue: [], isPaused: false })) + panel.calls.length = 0 + }) + await t.test('changes that come while the dashboard is hidden are not read, except the Twitch status, which is shown on every tab', async () => { switchPageTab('settings') panel.calls.length = 0 diff --git a/tests/frontend/overlay.test.js b/tests/frontend/overlay.test.js index 749df2d..7c486e1 100644 --- a/tests/frontend/overlay.test.js +++ b/tests/frontend/overlay.test.js @@ -120,6 +120,19 @@ test('the player follows the server', async (t) => { assert.deepEqual(player().calls.at(-1), ['load', V('b')]) }) + await t.test('the same track played again (a new play id) is loaded again', async (t) => { + const { overlay, server, player, settle } = await boot(t, { current: track('a', { playId: 1 }) }) + + server.current = track('a', { playId: 2 }) + await overlay.refresh() + await settle() + + assert.deepEqual(player().calls, [ + ['load', V('a')], + ['load', V('a')] + ]) + }) + await t.test('pause and resume of the server reach the player, and an empty queue stops it', async (t) => { const { overlay, server, player, settle } = await boot(t, { current: track('a') }) const step = async (change) => { @@ -151,6 +164,15 @@ test('a finished track', async (t) => { assert.deepEqual(sent.ended, [{ videoId: V('a') }]) }) + await t.test('is reported with the play id of the play that ended', async (t) => { + const { player, sent, settle } = await boot(t, { current: track('a', { playId: 7 }) }) + + player().emit('onStateChange', 0) + await settle() + + assert.deepEqual(sent.ended, [{ videoId: V('a'), playId: 7 }]) + }) + await t.test('is reported again, with growing pauses, until the server has heard it', async (t) => { const { player, sent, outcomes, settle, elapse } = await boot(t, { current: track('a') }) outcomes.ended.push(refused, refused) From 759497aef1ca7c61a17e7cc043324d60bf556aea Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:34:58 +0700 Subject: [PATCH 11/22] Unify track completion and harden queue advancement Track lifecycle: - route failures, rejections, and skips through `finishItem` for consistent activity logging - preserve existing Channel Points behavior Queue: - make queue advancement atomic - exclude blocked tracks from per-user request limits and chat listings Fallback playlists: - mark playlist tracks as fallback entries - use a localized requester name for fallback tracks --- public/locales/en.json | 3 +- public/locales/ru.json | 3 +- scripts/smoke-frontend.mjs | 20 ++--- src/activity.ts | 29 +------ src/chat-replies.ts | 17 ++-- src/fallback.ts | 13 +++- src/finish.ts | 53 +++++++++++-- src/integrations/twitch/index.ts | 4 +- src/player.ts | 77 +++++++++--------- src/queue.ts | 78 +++++++++++-------- src/routes/player.ts | 3 +- src/state-file.ts | 3 +- tests/backend/activity.test.ts | 33 ++++++-- tests/backend/announcements.test.ts | 18 ++++- tests/backend/chat-replies.test.ts | 31 +++++++- tests/backend/fallback.test.ts | 34 +++++++- tests/backend/finish.test.ts | 21 ++++- .../integrations/twitch/chat-commands.test.ts | 4 +- tests/backend/player.blocklist.test.ts | 16 +++- tests/backend/player.test.ts | 71 ++++++++++++++--- tests/backend/queue.hydrate.test.ts | 4 +- tests/backend/queue.user-limit.test.ts | 63 +++++++++++---- tests/backend/routes/player.test.ts | 14 +++- tests/backend/state-file.test.ts | 8 +- 24 files changed, 437 insertions(+), 183 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index 7853436..4d1d369 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -86,7 +86,8 @@ "info": "{{count}} tracks ▪ updated {{datetime}}", "addToQueue": "Add to queue", "playNow": "Play now", - "playing": "Playing" + "playing": "Playing", + "requester": "Playlist" }, "activity": { "title": "Activity", diff --git a/public/locales/ru.json b/public/locales/ru.json index 9dcb2d2..941b09d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -86,7 +86,8 @@ "info": "{{count}} треков ▪ обновлено {{datetime}}", "addToQueue": "Добавить в очередь", "playNow": "Воспроизвести сейчас", - "playing": "Играет" + "playing": "Играет", + "requester": "Плейлист" }, "activity": { "title": "Активность", diff --git a/scripts/smoke-frontend.mjs b/scripts/smoke-frontend.mjs index 8c1bb94..79cdcc6 100644 --- a/scripts/smoke-frontend.mjs +++ b/scripts/smoke-frontend.mjs @@ -66,20 +66,14 @@ const routes = { }), 'GET /api/state': () => { if (failStateWith500) return [500, { error: 'boom', code: 'SERVER_ERROR' }] - const state = stateOverride ?? { current: track('a'), queue: [track('b'), track('c')], isPaused: false, nextTrack: null } - return { ...state, queue: withBlocked(state.queue) } - }, - 'GET /api/fallback': () => { - const current = stateOverride?.current ?? null - return { - upNext: withBlocked([track('e'), track('f')]), - activeVideoId: current?.isFallback ? current.videoId : V('f'), - lastRefreshedAt: Date.now(), - sourceCount: 2, - shuffle: false, - repeat: true, - enabled: true + // the playlist is playing: like the server, the current track says so with isFallback + const state = stateOverride ?? { + current: track('f', { isFallback: true, requestedBy: 'Playlist' }), + queue: [track('b'), track('c')], + isPaused: false, + nextTrack: null } + return { ...state, queue: withBlocked(state.queue) } }, 'GET /api/fallback': () => ({ upNext: withBlocked([track('e'), track('f')]), diff --git a/src/activity.ts b/src/activity.ts index ab1c743..0ad1be5 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,4 +1,4 @@ -import { ActivityEntry, ActivityReasonCode, QueueItem } from './types.js' +import { ActivityEntry, ActivityReasonCode } from './types.js' import { dataPath, createFileStore, loadList } from './persist.js' import { createLogger } from './logger.js' import { createStore } from './store.js' @@ -81,30 +81,3 @@ export function logRejection( export function logAcceptance(requestedBy: string, query: string, title: string, videoId: string): void { logActivity({ requestedBy, query, title, videoId, status: 'accepted', reasonCode: null }) } - -/** - * A track bought with Channel Points was skipped. Skipping does not touch the reward (the streamer or a moderator decides): - * it stays UNFULFILLED on Twitch with the points held, and this entry is where that can be seen. - */ -export function logSkippedWithOpenReward(item: QueueItem): void { - logActivity({ - requestedBy: item.requestedBy, - query: item.url, - title: item.title, - videoId: item.videoId, - status: 'skipped', - reasonCode: 'REWARD_NOT_REFUNDED' - }) -} - -export function logFailure(item: QueueItem, reasonCode: ActivityReasonCode, reasonParams?: ReasonParams): void { - logActivity({ - requestedBy: item.requestedBy, - query: item.url, - title: item.title, - videoId: item.videoId, - status: 'failed', - reasonCode, - reasonParams - }) -} diff --git a/src/chat-replies.ts b/src/chat-replies.ts index d0601f2..5a1e43b 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -1,5 +1,5 @@ import { t, hasKey } from './i18n.js' -import { type FailureReason, type PlayerState } from './types.js' +import { type Blockable, type FailureReason, type PlayerState, type QueueItem } from './types.js' const truncate = (s: string, max = 40) => { const chars = Array.from(s) @@ -22,20 +22,23 @@ export function buildSkipMessage(state: PlayerState, locale: string): string { return state.current?.title ? t(locale, 'chat.skippedNowPlaying', { title: state.current.title }) : t(locale, 'chat.skipped') } -// "Queue [N]: 1. Title | 2. Title ... [+more]" / "The queue is empty" - shared by the HTTP chat endpoint and the Twitch chat module -export function buildQueueMessage(state: PlayerState, locale: string): string { - if (state.queue.length === 0) { +// "Queue [N]: 1. Title | 2. Title ... [+more]" / "The queue is empty" - shared by the HTTP chat endpoint and the Twitch chat module. +// Blocked tracks are not upcoming: they are closed when their turn comes, so they are neither shown nor counted +export function buildQueueMessage(state: { queue: Blockable<QueueItem>[] }, locale: string): string { + const upcoming = state.queue.filter((item) => !item.isBlocked) + + if (upcoming.length === 0) { return t(locale, 'chat.queueEmpty') } const maxShown = 4 - const list = state.queue + const list = upcoming .slice(0, maxShown) .map((item, i) => `${i + 1}. ${truncate(item.title)}`) .join(' | ') - const base = t(locale, 'chat.queueList', { count: state.queue.length, list }) - const more = state.queue.length > maxShown ? t(locale, 'chat.queueMore', { count: state.queue.length - maxShown }) : '' + const base = t(locale, 'chat.queueList', { count: upcoming.length, list }) + const more = upcoming.length > maxShown ? t(locale, 'chat.queueMore', { count: upcoming.length - maxShown }) : '' return base + more } diff --git a/src/fallback.ts b/src/fallback.ts index 595720d..a013165 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -3,15 +3,19 @@ import { getConfig, updateConfig, onConfigChange } from './config.js' import { fetchPlaylistSongs } from './youtube/index.js' import { Song, QueueItem, FallbackPlaylist, FallbackStateResponse, AppError } from './types.js' import { addSong, getCurrent, setCurrent } from './queue.js' +import { finishItem } from './finish.js' import { on, emit } from './state-events.js' import { isBlocked, markBlocked } from './blocklist.js' +import { t } from './i18n.js' +import { getSettings } from './settings.js' import { createStore } from './store.js' import { persistState } from './state-file.js' import { createLogger } from './logger.js' const log = createLogger('FALLBACK') -export const FALLBACK_REQUESTER = 'Playlist' +// the name shown wherever the playlist "requests" a track; the queue counts such tracks as nobody's +const fallbackRequester = (): string => t(getSettings().locale, 'fallback.requester') export type FallbackSnapshot = { sourceTracks: Song[] @@ -91,7 +95,7 @@ function buildOrder(tracks: Song[], shuffleOn: boolean, currentVideoId: string | function toFallbackQueueItem(song: Song): QueueItem { return { ...song, - requestedBy: FALLBACK_REQUESTER, + requestedBy: fallbackRequester(), isFallback: true } } @@ -318,6 +322,9 @@ export function playFallbackTrackNow(videoId: string): QueueItem | null { throw new AppError('BLOCKED') } + // choosing a track to play right away skips whatever is playing now, open reward and all + finishItem(getCurrent(), 'skipped') + rotation.update(() => { fallbackCursor = index }) @@ -331,7 +338,7 @@ export function queueFallbackTrack(videoId: string): QueueItem | null { const song = findTrack(videoId) if (!song) return null - return addSong(song, FALLBACK_REQUESTER, { bypassLimits: true }).item + return addSong(song, fallbackRequester(), { bypassFilters: true, isFallback: true }).item } export const getFallbackSourceTracks = (): Song[] => [...fallbackTracksById.values()] diff --git a/src/finish.ts b/src/finish.ts index 9d3d635..9d9f981 100644 --- a/src/finish.ts +++ b/src/finish.ts @@ -1,10 +1,11 @@ -import type { FailureReason, QueueItem } from './types.js' +import type { ActivityReasonCode, ActivityStatus, FailureReason, QueueItem } from './types.js' +import { logActivity } from './activity.js' import { createLogger, describeError } from './logger.js' type Tracked = NonNullable<QueueItem['channelPointsRedemption']> export type RedemptionOutcome = { status: 'succeeded' } | { status: 'failed'; reason: FailureReason } -export type ItemOutcome = 'played' | FailureReason +export type ItemOutcome = 'played' | 'skipped' | FailureReason const log = createLogger('FINISH') @@ -16,16 +17,54 @@ export function registerRedemptionHandler(next: typeof handler): void { handler = next } +type ReasonParams = Record<string, string | number> + +// the journal entry a track writes when it leaves without playing through: who asked for it, what it was, +// and why it never played. `query` keeps what identifies the track (the requester typed a title, not a url) +function journal(item: QueueItem, status: ActivityStatus, reasonCode: ActivityReasonCode, reasonParams?: ReasonParams): void { + logActivity({ + requestedBy: item.requestedBy, + query: item.url, + title: item.title, + videoId: item.videoId, + status, + reasonCode, + reasonParams + }) +} + /** - * The single place where a track leaves the queue or the player for good. - * 'played' fulfils its Channel Points redemption, any reason cancels it (points refunded, requester told in chat). - * Tracks without a redemption (chat requests, playlist tracks) are ignored. + * The single place where a track leaves the queue or the player for good. The outcome owns the way out: + * it decides the journal entry and what happens to the Channel Points redemption. + * + * 'played' fulfils the redemption, any reason cancels it (points refunded, requester told in chat), + * 'skipped' leaves the reward open on Twitch for the streamer to decide. Tracks without a redemption + * (chat requests, playlist tracks) skip the reward part, but the journal is written either way. * * The track owns its reward: once closed it is gone from the track, so no other path can close it again. */ export function finishItem(item: QueueItem | null | undefined, outcome: ItemOutcome): void { - const tracked = item?.channelPointsRedemption - if (!item || !tracked) return + if (!item) return + + if (outcome === 'skipped') { + // the streamer or a moderator skipped the track on purpose: the reward stays UNFULFILLED on Twitch + // with the points held, and this entry is where that can be seen + if (item.channelPointsRedemption) { + log.warn(`"${item.title}" was bought with Channel Points: the reward stays UNFULFILLED on Twitch until it is fulfilled or refunded there`) + journal(item, 'skipped', 'REWARD_NOT_REFUNDED') + } + return + } + + if (typeof outcome === 'object') { + // a block is a refusal; a playback error is a failure. A removal or a cleared queue is the streamer's + // own doing, and the chat reply already tells the requester, so the journal stays quiet about those + if (outcome.code === 'BLOCKED') journal(item, 'rejected', outcome.code, outcome.params) + else if (outcome.code !== 'TRACK_REMOVED' && outcome.code !== 'QUEUE_CLEARED') journal(item, 'failed', outcome.code, outcome.params) + } + + const tracked = item.channelPointsRedemption + if (!tracked) return // the track owns its reward: once closed it is gone from the track, so no other path can close it again delete item.channelPointsRedemption diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 3829677..468fe64 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -11,7 +11,7 @@ import type { TwitchChatPermission } from './types.js' import { clearTwitchAuthorization, clearTwitchOAuthState, getPublicSecretsView, getSecrets, updateTwitchOAuthState } from '../../secrets.js' -import { detachChannelPointsRedemptions, requestSong, setPaused } from '../../queue.js' +import { detachChannelPointsRedemptions, requestSong } from '../../queue.js' import { buildNowPlayingMessage, buildQueueMessage, @@ -21,7 +21,7 @@ import { buildRedemptionRefundFailedMessage } from '../../chat-replies.js' import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' -import { getState, skipCurrent } from '../../player.js' +import { getState, skipCurrent, setPaused } from '../../player.js' import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish.js' import { AppError, type AddedSong, type FailureReason } from '../../types.js' import type { QueueItem } from '../../types.js' diff --git a/src/player.ts b/src/player.ts index 4740e66..243161c 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,13 +1,31 @@ import { StateResponse, QueueItem, ActivityReasonCode } from './types.js' import { isBlocked, markBlocked } from './blocklist.js' -import { logRejection, logFailure } from './activity.js' -import { getQueue, getCurrent, getIsPaused, setCurrent, shiftQueue } from './queue.js' +import { getQueue, getCurrent, setCurrent, takeNextPlayable } from './queue.js' import { peekNextFallbackTrack, advanceFallback } from './fallback.js' import { finishItem } from './finish.js' +import { createStore } from './store.js' import { createLogger } from './logger.js' const log = createLogger('PLAYER') +let isPaused = false + +// pausing is part of the announced state, but of nothing saved: it never survives a restart anyway +const paused = createStore({ + read: () => isPaused, + topics: ['state'] +}) + +export function setPaused(value: boolean): void { + paused.update(() => { + isPaused = value + }) +} + +export function getIsPaused(): boolean { + return isPaused +} + export function getNextTrack(): QueueItem | null { return getQueue().find((item) => !isBlocked(item.videoId)) ?? peekNextFallbackTrack() } @@ -22,23 +40,12 @@ export function getState(): StateResponse { } export function moveToNext(): QueueItem | null { - let next = shiftQueue() + const next = takeNextPlayable() + if (next) return next - while (next && isBlocked(next.videoId)) { - log.log(`skipped blocked track in queue: "${next.title}"`) - logRejection(next.requestedBy, next.title, 'BLOCKED', { title: next.title, videoId: next.videoId }) - finishItem(next, { code: 'BLOCKED' }) - next = shiftQueue() - } - - if (next) { - setCurrent(next) - log.log(`moved to next: "${next.title}"`) - } else { - const fallback = advanceFallback() - setCurrent(fallback) - if (fallback) log.log(`started fallback: "${fallback.title}"`) - } + const fallback = advanceFallback() + setCurrent(fallback) + if (fallback) log.log(`started fallback: "${fallback.title}"`) return getCurrent() } @@ -48,6 +55,7 @@ export function skipCurrent(): QueueItem | null { if (skipped) { log.log(`skipped "${skipped.title}"`) } + finishItem(skipped, 'skipped') return moveToNext() } @@ -61,16 +69,16 @@ export function skipIfCurrent(videoId: string): boolean { return true } -export function playbackFailureReasonCode(errorCode?: number): ActivityReasonCode { - switch (errorCode) { - case 100: - return 'PLAYBACK_VIDEO_UNAVAILABLE' - case 101: - case 150: - return 'PLAYBACK_EMBED_DISALLOWED' - default: - return 'PLAYBACK_FAILED' - } +// the YouTube iframe error codes that say why a track cannot be played +const PLAYBACK_FAILURE_REASONS: Record<number, ActivityReasonCode> = { + 100: 'PLAYBACK_VIDEO_UNAVAILABLE', + 101: 'PLAYBACK_EMBED_DISALLOWED', + 150: 'PLAYBACK_EMBED_DISALLOWED' +} + +function playbackFailureReasonCode(errorCode?: number): ActivityReasonCode { + if (errorCode !== undefined) return PLAYBACK_FAILURE_REASONS[errorCode] ?? 'PLAYBACK_FAILED' + return 'PLAYBACK_FAILED' } function isAboutCurrent(videoId?: string, playId?: number): boolean { @@ -91,20 +99,19 @@ export function endCurrent(videoId?: string, playId?: number): boolean { return true } -// The player reports that a play of `videoId` failed. Returns false if that was not the current play and nothing changed +// The player reports that a play of `videoId` failed. Returns false if nothing was playing or the report was +// about another play: a stray report must not start the fallback on its own export function reportPlaybackFailure(errorCode?: number, videoId?: string, playId?: number): boolean { - if (!isAboutCurrent(videoId, playId)) { - log.log(`ignored playback failure for ${videoId}: it is not the current track`) + if (getCurrent() === null || !isAboutCurrent(videoId, playId)) { + log.log(`ignored playback failure for ${videoId ?? 'an unnamed track'}: it is not the current track`) return false } const failed = getCurrent() - if (failed) { const reasonCode = playbackFailureReasonCode(errorCode) - log.error(`playback failed: "${failed.title}" (error ${errorCode ?? 'unknown'})`) - logFailure(failed, reasonCode, errorCode !== undefined ? { errorCode } : undefined) - + // a track that cannot be played is routine (the journal and the chat reply say why): an error entry would be noise + log.warn(`playback failed: "${failed.title}" (error ${errorCode ?? 'unknown'})`) finishItem(failed, { code: reasonCode, params: errorCode !== undefined ? { errorCode } : undefined }) } diff --git a/src/queue.ts b/src/queue.ts index c3d544b..1671a10 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -1,7 +1,7 @@ import { getConfig } from './config.js' import { getVideoById, searchSongs, selectBestSong } from './youtube/index.js' import { parseYouTubeUrl } from './youtube/url.js' -import { logRejection, logAcceptance, logSkippedWithOpenReward } from './activity.js' +import { logRejection, logAcceptance } from './activity.js' import { QueueItem, Song, AppError, AddedSong } from './types.js' import { isBlocked } from './blocklist.js' import { createStore } from './store.js' @@ -12,12 +12,11 @@ import { createLogger } from './logger.js' let currentSong: QueueItem | null = null const queue: QueueItem[] = [] -let isPaused = false let playCounter = 0 // the playing state is saved and announced by the store when it changes const playing = createStore({ - read: () => ({ current: currentSong, queue, isPaused }), + read: () => ({ current: currentSong, queue }), topics: ['state'], save: persistState }) @@ -29,7 +28,8 @@ const isQueued = (videoId: string): boolean => queue.some((item) => item.videoId function getUserActiveCount(username: string): number { const key = username.toLowerCase() - const isTheirs = (item: QueueItem): boolean => item.requestedBy.toLowerCase() === key + // a blocked track never plays, so it no longer occupies the viewer's slot + const isTheirs = (item: QueueItem): boolean => !isBlocked(item.videoId) && item.requestedBy.toLowerCase() === key const playing = currentSong !== null && !currentSong.isFallback && isTheirs(currentSong) return queue.filter(isTheirs).length + (playing ? 1 : 0) @@ -83,33 +83,57 @@ export function getCurrent(): QueueItem | null { return currentSong } -export function setPaused(value: boolean): void { +// Dequeues the next playable track and makes it the current one, in one state change (saved and announced +// once). Blocked tracks are closed on the way. Returns null when no playable track is left; whatever plays +// then stays the current one until the caller decides what follows +export function takeNextPlayable(): QueueItem | null { + const closedOnTheWay: QueueItem[] = [] + const taken: QueueItem[] = [] + playing.update(() => { - isPaused = value + for (;;) { + const candidate = queue.shift() + if (!candidate) break + if (isBlocked(candidate.videoId)) { + closedOnTheWay.push(candidate) + continue + } + candidate.playId = ++playCounter + currentSong = candidate + taken.push(candidate) + break + } }) -} -export function getIsPaused(): boolean { - return isPaused -} + for (const item of closedOnTheWay) { + log.log(`skipped blocked track in queue: "${item.title}"`) + finishItem(item, { code: 'BLOCKED' }) + } + + const next = taken[0] ?? null + if (next) log.log(`moved to next: "${next.title}"`) -export function shiftQueue(): QueueItem | null { - return queue.shift() ?? null + return next } export type AddOptions = { - bypassLimits?: boolean + // an admin request (from this computer): neither the content filters nor the request limits apply + bypassFilters?: boolean channelPointsRedemption?: QueueItem['channelPointsRedemption'] + // set for the tracks the playlist itself queues: they count as nobody's request + isFallback?: boolean } -function assertCanRequestSong(requestedBy: string, { bypassLimits = false }: AddOptions = {}): void { +function assertCanRequestSong(requestedBy: string, { bypassFilters = false }: AddOptions = {}): void { const config = getConfig() - if (currentSong !== null && queue.length >= config.maxQueueSize) { + // blocked tracks do not occupy places: they are closed when their turn comes + const playable = queue.filter((item) => !isBlocked(item.videoId)).length + if (currentSong !== null && playable >= config.maxQueueSize) { throw new AppError('QUEUE_FULL') } - if (bypassLimits) return + if (bypassFilters) return const activeCount = getUserActiveCount(requestedBy) if (config.maxRequestsPerUser > 0 && activeCount >= config.maxRequestsPerUser) { @@ -141,6 +165,7 @@ export function addSong(song: Song, requestedBy: string, options: AddOptions = { const item: QueueItem = { ...song, requestedBy, + ...(options.isFallback ? { isFallback: true } : {}), ...(options.channelPointsRedemption ? { channelPointsRedemption: options.channelPointsRedemption } : {}) } const started = currentSong === null @@ -160,12 +185,6 @@ export function addSong(song: Song, requestedBy: string, options: AddOptions = { export function setCurrent(item: QueueItem | null): void { const previous = currentSong - // still attached = nobody closed it, so the track was skipped or replaced with its reward open - if (previous?.channelPointsRedemption && previous !== item) { - log.warn(`"${previous.title}" was bought with Channel Points: the reward stays UNFULFILLED on Twitch until it is fulfilled or refunded there`) - logSkippedWithOpenReward(previous) - } - if (item?.videoId !== previous?.videoId) { log.log(item ? `now playing "${item.title}"` : 'nothing is playing') } @@ -184,7 +203,7 @@ export function removeByVideoId(videoId: string): QueueItem | null { return removeAt(queue.findIndex((item) => item.videoId === videoId)) } -export function removeAt(index: number): QueueItem | null { +function removeAt(index: number): QueueItem | null { if (index < 0 || index >= queue.length) { return null } @@ -218,20 +237,13 @@ export function clearQueue(): QueueItem[] { return cleared } -type RequestOptions = { - // an admin request (from this computer): the filters and the limits do not apply - bypassFilters?: boolean - // set when a Channel Points redemption paid for the request: the track carries it until the redemption is closed - channelPointsRedemption?: QueueItem['channelPointsRedemption'] -} - // Adds the song the query names to the queue, or throws the AppError that says why not (a duplicate, // a limit, a filter, a bad URL); the activity journal entry is written on the way out, in one place -export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: RequestOptions = {}): Promise<AddedSong> { +export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: AddOptions = {}): Promise<AddedSong> { let song: Song | null = null try { - assertCanRequestSong(requestedBy, { bypassLimits: bypassFilters }) + assertCanRequestSong(requestedBy, { bypassFilters }) const { isYouTube, videoId } = parseYouTubeUrl(query) log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) @@ -254,7 +266,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi if (!song) throw new AppError('SONG_NOT_FOUND') - const { item, started } = addSong(song, requestedBy, { bypassLimits: bypassFilters, channelPointsRedemption }) + const { item, started } = addSong(song, requestedBy, { bypassFilters, channelPointsRedemption }) log.log(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) logAcceptance(requestedBy, query, song.title, song.videoId) diff --git a/src/routes/player.ts b/src/routes/player.ts index c778e05..619c3f1 100644 --- a/src/routes/player.ts +++ b/src/routes/player.ts @@ -3,8 +3,7 @@ import { PlayerActionResponse } from '../types.js' import { ok } from '../http.js' import { t } from '../i18n.js' import { buildSkipMessage } from '../chat-replies.js' -import { setPaused } from '../queue.js' -import { getState, endCurrent, skipCurrent, reportPlaybackFailure } from '../player.js' +import { getState, endCurrent, skipCurrent, reportPlaybackFailure, setPaused } from '../player.js' import { getSettings } from '../settings.js' import { checkVideoId } from '../validate.js' diff --git a/src/state-file.ts b/src/state-file.ts index 83d3be9..9ee32cb 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -70,7 +70,8 @@ export function sanitizeQueueItem(raw: unknown): QueueItem | null { return { ...song, requestedBy, - ...(isObject(raw) && raw.isFallback === true ? { isFallback: true } : {}), + // a queue saved before the flag existed marked its playlist tracks only by the requester name + ...(isObject(raw) && (raw.isFallback === true || requestedBy.toLowerCase() === 'playlist') ? { isFallback: true } : {}), ...(playId !== undefined ? { playId } : {}), ...(redemption ? { channelPointsRedemption: redemption } : {}) } diff --git a/tests/backend/activity.test.ts b/tests/backend/activity.test.ts index 7aa60d2..e4081dd 100644 --- a/tests/backend/activity.test.ts +++ b/tests/backend/activity.test.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { getActivity, clearActivity, logRejection, logAcceptance, logFailure } = await import('../../src/activity.js') +const { getActivity, clearActivity, logRejection, logAcceptance } = await import('../../src/activity.js') +const { finishItem } = await import('../../src/finish.js') beforeEach(() => clearActivity()) @@ -47,17 +48,39 @@ test('activity entry helpers', async (t) => { assert.equal(last().title, 'Song') }) - await t.test('logFailure: a queued track that could not be played, attributed to whoever requested it', () => { - logFailure( + await t.test('finishItem: a track that could not be played is journaled as failed, attributed to whoever requested it', () => { + finishItem( { videoId: 'ccccccccccc', title: 'Broken', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'zed' }, - 'PLAYBACK_EMBED_DISALLOWED', - { errorCode: 101 } + { code: 'PLAYBACK_EMBED_DISALLOWED', params: { errorCode: 101 } } ) assert.equal(last().status, 'failed') assert.equal(last().requestedBy, 'zed') // the title is in `title` already; `query` keeps what identifies the track assert.equal(last().query, 'u') + assert.equal(last().reasonCode, 'PLAYBACK_EMBED_DISALLOWED') assert.deepEqual(last().reasonParams, { errorCode: 101 }) }) + + await t.test('finishItem: a blocked track is journaled as a refusal', () => { + finishItem( + { videoId: 'ddddddddddd', title: 'Blocked', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u2', requestedBy: 'zed' }, + { code: 'BLOCKED' } + ) + + assert.equal(last().status, 'rejected') + assert.equal(last().reasonCode, 'BLOCKED') + }) +}) + +test('the journal of a way out does not need a redemption', async (t) => { + await t.test('a removed or cleared track writes nothing: the streamer did it on purpose', () => { + const track = { videoId: 'eeeeeeeeeee', title: 'Gone', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u3', requestedBy: 'zed' } + + finishItem(track, { code: 'TRACK_REMOVED' }) + finishItem(track, { code: 'QUEUE_CLEARED' }) + finishItem(track, 'played') + + assert.deepEqual(getActivity(), []) + }) }) diff --git a/tests/backend/announcements.test.ts b/tests/backend/announcements.test.ts index a2a683a..1462549 100644 --- a/tests/backend/announcements.test.ts +++ b/tests/backend/announcements.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const queue = await import('../../src/queue.js') +const player = await import('../../src/player.js') const fallback = await import('../../src/fallback.js') const { upsertPlaylist, removePlaylist } = await import('../../src/playlists.js') const { updateConfig } = await import('../../src/config.js') @@ -54,18 +55,31 @@ test('a new current track', async (t) => { test('pausing', async (t) => { await t.test('announces the state', () => { - queue.setPaused(true) + player.setPaused(true) assert.deepEqual(announced(), ['state']) }) await t.test('pausing to the same value announces nothing: nothing changed', () => { - queue.setPaused(true) + player.setPaused(true) assert.deepEqual(announced(), []) }) }) +test('moving on to the next track', async (t) => { + await t.test('leaving the old one and taking the next is one state change, announced once', () => { + queue.setCurrent({ ...song('viewer000001'), requestedBy: 'viewer' }) + queue.addSong(song('viewer000002'), 'viewer', { bypassFilters: true }) + heard.length = 0 + + player.moveToNext() + + assert.deepEqual(heard, ['state']) + assert.equal(queue.getCurrent()?.videoId, 'viewer000002') + }) +}) + test('the switches of the playlist', async (t) => { for (const [name, toggle] of [ ['shuffle', fallback.toggleFallbackShuffle], diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 3dd4ad2..4ea7056 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -12,7 +12,36 @@ const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) cpSync(locales, join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) -const { buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage } = await import('../../src/chat-replies.js') +const { buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage, buildQueueMessage } = await import('../../src/chat-replies.js') + +test('buildQueueMessage', async (t) => { + const track = (videoId: string, isBlocked = false) => ({ videoId, title: `Track ${videoId}`, requestedBy: 'viewer', isBlocked }) + + await t.test('lists the first titles and counts everything', () => { + const message = buildQueueMessage({ queue: [track('a'), track('b')] }, 'en') + + assert.equal(message, 'Queue [2]: 1. Track a | 2. Track b') + }) + + await t.test('blocked tracks are not upcoming: not listed, not counted', () => { + const message = buildQueueMessage({ queue: [track('a', true), track('b'), track('c', true)] }, 'en') + + assert.equal(message, 'Queue [1]: 1. Track b') + }) + + await t.test('a queue with nothing but blocked tracks reads as empty', () => { + const message = buildQueueMessage({ queue: [track('a', true)] }, 'en') + + assert.equal(message, 'The queue is empty') + }) + + await t.test('the tail past the fourth title is summed up instead of listed', () => { + const queue = [track('a'), track('b'), track('c'), track('d'), track('e'), track('f', true)] + const message = buildQueueMessage({ queue }, 'en') + + assert.equal(message, 'Queue [5]: 1. Track a | 2. Track b | 3. Track c | 4. Track d [+1]') + }) +}) test('buildRedemptionAcceptedMessage', async (t) => { await t.test('names the track and its place in the queue', () => { diff --git a/tests/backend/fallback.test.ts b/tests/backend/fallback.test.ts index 63059b8..6a08964 100644 --- a/tests/backend/fallback.test.ts +++ b/tests/backend/fallback.test.ts @@ -1,15 +1,20 @@ import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' +import { cpSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' -process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) +// the playlist's requester name comes from the real locale files +// (copied, not symlinked: creating a symlink needs extra privileges on Windows) +const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) +cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) +process.chdir(workDir) const fallback = await import('../../src/fallback.js') const { getConfig, updateConfig } = await import('../../src/config.js') const { setCurrent } = await import('../../src/queue.js') const { on } = await import('../../src/state-events.js') +const { updateSettings } = await import('../../src/settings.js') const IDS = Array.from({ length: 12 }, (_, i) => `track${String(i).padStart(2, '0')}`) const song = (videoId: string) => ({ @@ -91,3 +96,26 @@ test('state persistence hooks', async (t) => { assert.equal(fallback.getFallbackProgress().playlistId, null) }) }) + +test('the tracks the playlist itself queues', async (t) => { + await t.test('queueFallbackTrack() marks the track as the playlist\'s own', () => { + setCurrent({ ...song('playing000001'), requestedBy: 'viewer' }) + + const item = fallback.queueFallbackTrack('track00') + + assert.equal(item?.isFallback, true) + assert.equal(item?.requestedBy, 'Playlist') + assert.equal(fallback.queueFallbackTrack('nosuchtrack00'), null) + }) + + await t.test('the name follows the locale', async () => { + updateSettings({ locale: 'ru' }) + setCurrent({ ...song('playing000001'), requestedBy: 'viewer' }) + + assert.equal(fallback.queueFallbackTrack('track05')?.requestedBy, 'Плейлист') + assert.equal(fallback.advanceFallback()?.requestedBy, 'Плейлист') + + updateSettings({ locale: 'en' }) + assert.equal(fallback.peekNextFallbackTrack()?.requestedBy, 'Playlist') + }) +}) diff --git a/tests/backend/finish.test.ts b/tests/backend/finish.test.ts index c7cc277..891d4a2 100644 --- a/tests/backend/finish.test.ts +++ b/tests/backend/finish.test.ts @@ -13,6 +13,8 @@ const queue = await import('../../src/queue.js') const player = await import('../../src/player.js') const { blockTrack, unblockTrack } = await import('../../src/blocklist.js') const { sanitizeQueueItem } = await import('../../src/state-file.js') +const fallback = await import('../../src/fallback.js') +const B2 = 'b2b2b2b2b2b2' const song = (videoId: string) => ({ videoId, @@ -106,6 +108,21 @@ test('a Channel Points redemption is closed by the way its track leaves', async assert.deepEqual(getActivity(), []) }) + await t.test('playing a playlist track right away skips what is playing now the same way', () => { + queue.setCurrent({ ...song(A), requestedBy: 'user', channelPointsRedemption: redemption('r1') }) + fallback.hydrateFallback({ sourceTracks: [song(B2)], order: [B2], cursor: -1, playlistId: 'PLskiptest00001', lastRefreshedAt: null }) + + const item = fallback.playFallbackTrackNow(B2) + + assert.equal(item?.videoId, B2) + assert.deepEqual(outcomes, [], 'the reward is left for the streamer, like any skip') + const [entry, ...others] = getActivity() + assert.equal(others.length, 0) + assert.equal(entry.status, 'skipped') + assert.equal(entry.reasonCode, 'REWARD_NOT_REFUNDED') + assert.equal(entry.videoId, A) + }) + await t.test('a redemption is closed only once', () => { const item = { ...song(A), requestedBy: 'user', channelPointsRedemption: redemption('r1') } queue.setCurrent(item) @@ -131,7 +148,7 @@ test('removeByVideoId()', async (t) => { add(A, 'r1') add(B, 'r2') add(C, 'r3') - queue.shiftQueue() // A started playing: B is now at index 0, where A was a moment ago + player.moveToNext() // A started playing: B is now at index 0, where A was a moment ago assert.equal(queue.removeByVideoId(C)?.videoId, C) assert.deepEqual( @@ -185,7 +202,7 @@ test('detachChannelPointsRedemptions()', async (t) => { queue.hydrateQueue({ queue: [{ ...song(B), requestedBy: 'u2', channelPointsRedemption: redemption('d3') }] }) queue.detachChannelPointsRedemptions() - queue.removeAt(0) + queue.removeByVideoId(B) assert.deepEqual(calls, []) registerRedemptionHandler(recordOutcome) diff --git a/tests/backend/integrations/twitch/chat-commands.test.ts b/tests/backend/integrations/twitch/chat-commands.test.ts index 15c8510..2fc3efb 100644 --- a/tests/backend/integrations/twitch/chat-commands.test.ts +++ b/tests/backend/integrations/twitch/chat-commands.test.ts @@ -8,8 +8,8 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-chat-test-'))) const { getConfig, updateConfig } = await import('../../../../src/config.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') -const { hydrateQueue, setPaused, getIsPaused } = await import('../../../../src/queue.js') -const { getState } = await import('../../../../src/player.js') +const { hydrateQueue } = await import('../../../../src/queue.js') +const { getState, setPaused, getIsPaused } = await import('../../../../src/player.js') const { _test, _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') const { clearTwitchOAuthState } = await import('../../../../src/secrets.js') diff --git a/tests/backend/player.blocklist.test.ts b/tests/backend/player.blocklist.test.ts index 5214005..e5f4f67 100644 --- a/tests/backend/player.blocklist.test.ts +++ b/tests/backend/player.blocklist.test.ts @@ -29,7 +29,7 @@ function setFallback(ids: string[], repeat = false) { fallback.hydrateFallback({ sourceTracks: ids.map(song), order: ids, cursor: -1, playlistId: 'PLtest0000001', lastRefreshedAt: null }) updateConfig({ fallbackPlaylist: { playlistId: 'PLtest0000001', enabled: true, shuffle: false, repeat } }) } -const enqueue = (...ids: string[]) => ids.forEach((id) => queue.addSong(song(id), 'viewer', { bypassLimits: true }).item) +const enqueue = (...ids: string[]) => ids.forEach((id) => queue.addSong(song(id), 'viewer', { bypassFilters: true }).item) const block = (...ids: string[]) => ids.forEach((id) => blocklist.blockTrack(id, `Track ${id}`)) beforeEach(() => { @@ -134,6 +134,20 @@ test('skipIfCurrent()', async (t) => { assert.equal(player.getState().current?.videoId, 'b') }) + await t.test('the blocked playing track is journaled, like a blocked queued one', () => { + enqueue('b') + playing('a') + + player.skipIfCurrent('a') + + const [entry, ...others] = getActivity().filter((e) => e.reasonCode === 'BLOCKED') + assert.equal(others.length, 0) + assert.equal(entry.status, 'rejected') + assert.equal(entry.requestedBy, 'viewer') + assert.equal(entry.videoId, 'a') + assert.equal(entry.title, 'Track a') + }) + await t.test('does nothing for any other track', () => { playing('a') diff --git a/tests/backend/player.test.ts b/tests/backend/player.test.ts index f631f62..933d1d2 100644 --- a/tests/backend/player.test.ts +++ b/tests/backend/player.test.ts @@ -1,4 +1,4 @@ -import { test } from 'node:test' +import { test, beforeEach } from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,20 +7,67 @@ import { join } from 'node:path' // the modules read data/ and cache/ relative to the working directory: keep the test away from the real ones process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { playbackFailureReasonCode } = await import('../../src/player.js') +const player = await import('../../src/player.js') +const queue = await import('../../src/queue.js') +const fallback = await import('../../src/fallback.js') +const { updateConfig } = await import('../../src/config.js') +const { getActivity, clearActivity } = await import('../../src/activity.js') -test('playbackFailureReasonCode', async (t) => { - await t.test('100 -> video unavailable', () => { - assert.equal(playbackFailureReasonCode(100), 'PLAYBACK_VIDEO_UNAVAILABLE') - }) +const song = (videoId: string) => ({ + videoId, + title: `Track ${videoId[0]}`, + channelTitle: 'C', + thumbnail: '', + duration: 200, + views: 1, + url: `https://youtu.be/${videoId}` +}) + +beforeEach(() => { + queue.setCurrent(null) + queue.clearQueue() + clearActivity() + fallback.hydrateFallback({ sourceTracks: [], order: [], cursor: -1, playlistId: null, lastRefreshedAt: null }) + updateConfig({ fallbackPlaylist: { enabled: false } }) +}) + +// the YouTube iframe error codes, as they reach the journal +const FAILING_CODES: [number | undefined, string][] = [ + [100, 'PLAYBACK_VIDEO_UNAVAILABLE'], + [101, 'PLAYBACK_EMBED_DISALLOWED'], + [150, 'PLAYBACK_EMBED_DISALLOWED'], + [2, 'PLAYBACK_FAILED'], + [undefined, 'PLAYBACK_FAILED'] +] + +test('reportPlaybackFailure() journals why the track could not be played', async (t) => { + const A = 'aaaaaaaaaaa' + + for (const [errorCode, reason] of FAILING_CODES) { + await t.test(`error ${errorCode} -> ${reason}`, () => { + queue.setCurrent({ ...song(A), requestedBy: 'viewer' }) + + assert.equal(player.reportPlaybackFailure(errorCode), true) + assert.equal(getActivity()[0]?.reasonCode, reason) + }) + } +}) + +test('reportPlaybackFailure() with nothing playing', async (t) => { + const A = 'aaaaaaaaaaa' + + await t.test('a stray report does not start the fallback playlist', () => { + fallback.hydrateFallback({ sourceTracks: [song(A)], order: [A], cursor: -1, playlistId: 'PLq10playtest01', lastRefreshedAt: null }) + updateConfig({ fallbackPlaylist: { enabled: true } }) + queue.setCurrent(null) - await t.test('101 and 150 -> embed disallowed', () => { - assert.equal(playbackFailureReasonCode(101), 'PLAYBACK_EMBED_DISALLOWED') - assert.equal(playbackFailureReasonCode(150), 'PLAYBACK_EMBED_DISALLOWED') + assert.equal(player.reportPlaybackFailure(101), false) + assert.equal(queue.getCurrent(), null, 'the playlist must not start on its own') }) - await t.test('other/unknown codes -> generic failed', () => { - assert.equal(playbackFailureReasonCode(2), 'PLAYBACK_FAILED') - assert.equal(playbackFailureReasonCode(undefined), 'PLAYBACK_FAILED') + await t.test('a report about a track that is not playing changes nothing', () => { + assert.equal(player.reportPlaybackFailure(101, 'bbbbbbbbbbb'), false) + assert.equal(queue.getCurrent(), null) + assert.equal(getActivity().length, 0) }) }) diff --git a/tests/backend/queue.hydrate.test.ts b/tests/backend/queue.hydrate.test.ts index 5891c22..e4a914d 100644 --- a/tests/backend/queue.hydrate.test.ts +++ b/tests/backend/queue.hydrate.test.ts @@ -37,8 +37,8 @@ test('hydrateQueue()', async (t) => { ) // Bob's two old entries are forgotten: with a limit of one request per user he may queue again updateConfig({ maxRequestsPerUser: 1 }) - assert.doesNotThrow(() => queue.assertCanAddSong(item('eeeeeeeeeee'), 'Bob', { bypassLimits: false })) - assert.throws(() => queue.assertCanAddSong(item('fffffffffff'), 'Amy', { bypassLimits: false }), { code: 'USER_LIMIT' }) + assert.doesNotThrow(() => queue.assertCanAddSong(item('eeeeeeeeeee'), 'Bob', { bypassFilters: false })) + assert.throws(() => queue.assertCanAddSong(item('fffffffffff'), 'Amy', { bypassFilters: false }), { code: 'USER_LIMIT' }) }) await t.test('a saved "nothing is playing" clears the current track instead of keeping the old one', () => { diff --git a/tests/backend/queue.user-limit.test.ts b/tests/backend/queue.user-limit.test.ts index 7fdc5d3..a5652be 100644 --- a/tests/backend/queue.user-limit.test.ts +++ b/tests/backend/queue.user-limit.test.ts @@ -8,6 +8,7 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const queue = await import('../../src/queue.js') const { updateConfig } = await import('../../src/config.js') +const { blockTrack, unblockTrack } = await import('../../src/blocklist.js') const song = (videoId: string) => ({ videoId, @@ -22,30 +23,31 @@ const song = (videoId: string) => ({ beforeEach(() => { queue.clearQueue() updateConfig({ maxRequestsPerUser: 1 }) + for (const id of ['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc', 'ddddddddddd', 'zzzzzzzzzzz']) unblockTrack(id) }) test('per-user request limit is case-insensitive', async (t) => { await t.test('the same user under a different letter case is still counted as one user', () => { queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) - queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassLimits: false }) + queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: false }) - assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'bob', { bypassLimits: false }), { code: 'USER_LIMIT' }) - assert.throws(() => queue.assertCanAddSong(song('ccccccccccc'), 'BOB', { bypassLimits: false }), { code: 'USER_LIMIT' }) + assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'bob', { bypassFilters: false }), { code: 'USER_LIMIT' }) + assert.throws(() => queue.assertCanAddSong(song('ccccccccccc'), 'BOB', { bypassFilters: false }), { code: 'USER_LIMIT' }) }) await t.test('a different user is not affected by another user being at their limit', () => { queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) - queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassLimits: false }) + queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: false }) - assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassLimits: false })) + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassFilters: false })) }) await t.test('removing the one request frees up the slot for any case of the same name', () => { queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) - const added = queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassLimits: false }).item - queue.removeAt(queue.getQueue().indexOf(added)) + const added = queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: false }).item + queue.removeByVideoId(added.videoId) - assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'BOB', { bypassLimits: false })) + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'BOB', { bypassFilters: false })) }) }) @@ -53,38 +55,65 @@ test('the track that is playing counts toward the per-user limit', async (t) => await t.test('a viewer whose track is playing is at the limit', () => { queue.setCurrent({ ...song('aaaaaaaaaaa'), requestedBy: 'Amy' }) - assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'amy', { bypassLimits: false }), { code: 'USER_LIMIT' }) - assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Bob', { bypassLimits: false })) + assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'amy', { bypassFilters: false }), { code: 'USER_LIMIT' }) + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Bob', { bypassFilters: false })) }) await t.test('a request that starts playing at once counts as well', () => { queue.setCurrent(null) - const { started } = queue.addSong(song('aaaaaaaaaaa'), 'Cy', { bypassLimits: false }) + const { started } = queue.addSong(song('aaaaaaaaaaa'), 'Cy', { bypassFilters: false }) assert.equal(started, true) - assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'cy', { bypassLimits: false }), { code: 'USER_LIMIT' }) + assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'cy', { bypassFilters: false }), { code: 'USER_LIMIT' }) }) await t.test('the playing track and the queued ones are counted together', () => { updateConfig({ maxRequestsPerUser: 2 }) queue.setCurrent({ ...song('aaaaaaaaaaa'), requestedBy: 'Amy' }) - assert.doesNotThrow(() => queue.addSong(song('bbbbbbbbbbb'), 'Amy', { bypassLimits: false })) - assert.throws(() => queue.assertCanAddSong(song('ccccccccccc'), 'Amy', { bypassLimits: false }), { code: 'USER_LIMIT' }) + assert.doesNotThrow(() => queue.addSong(song('bbbbbbbbbbb'), 'Amy', { bypassFilters: false })) + assert.throws(() => queue.assertCanAddSong(song('ccccccccccc'), 'Amy', { bypassFilters: false }), { code: 'USER_LIMIT' }) }) await t.test('the place is free again when the playing track is gone', () => { queue.setCurrent({ ...song('aaaaaaaaaaa'), requestedBy: 'Amy' }) - assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassLimits: false }), { code: 'USER_LIMIT' }) + assert.throws(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassFilters: false }), { code: 'USER_LIMIT' }) queue.setCurrent(null) - assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassLimits: false })) + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Amy', { bypassFilters: false })) }) await t.test('a track of the fallback playlist is nobody\'s request', () => { queue.setCurrent({ ...song('aaaaaaaaaaa'), requestedBy: 'Playlist', isFallback: true }) - assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Playlist', { bypassLimits: false })) + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Playlist', { bypassFilters: false })) + }) + + await t.test('a track the streamer blocked frees the viewer\'s slot: it never plays', () => { + queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) + queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: false }) + blockTrack('aaaaaaaaaaa', 'Track a') + + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'bob', { bypassFilters: false })) + }) +}) + +test('blocked tracks do not occupy the queue size', async (t) => { + t.beforeEach(() => { + queue.clearQueue() + updateConfig({ maxRequestsPerUser: 1, maxQueueSize: 1 }) + for (const id of ['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc', 'zzzzzzzzzzz']) unblockTrack(id) + }) + + await t.test('a queue full of blocked tracks still takes a request', () => { + queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) + queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: true }) + blockTrack('aaaaaaaaaaa', 'Track a') + + assert.doesNotThrow(() => queue.addSong(song('bbbbbbbbbbb'), 'Amy', { bypassFilters: true })) + + // one playable track is the limit here + assert.throws(() => queue.assertCanAddSong(song('ccccccccccc'), 'Cy', { bypassFilters: true }), { code: 'QUEUE_FULL' }) }) }) diff --git a/tests/backend/routes/player.test.ts b/tests/backend/routes/player.test.ts index fbf3211..b626a4a 100644 --- a/tests/backend/routes/player.test.ts +++ b/tests/backend/routes/player.test.ts @@ -42,7 +42,7 @@ const song = (videoId: string) => ({ beforeEach(() => { queue.clearQueue() queue.setCurrent({ ...song(A), requestedBy: 'viewer' }) - for (const id of [B, C, D]) queue.addSong(song(id), 'viewer', { bypassLimits: true }) + for (const id of [B, C, D]) queue.addSong(song(id), 'viewer', { bypassFilters: true }) clearActivity() }) @@ -136,9 +136,19 @@ test('POST /api/player/report-failure', async (t) => { await t.test('nothing is playing: any report is ignored', async () => { queue.setCurrent(null) - const body = (await (await post('report-failure', { videoId: A })).json()) as Record<string, any> + const withId = (await (await post('report-failure', { videoId: A })).json()) as Record<string, any> + + assert.equal(withId.data.ignored, true) + }) + + await t.test('nothing is playing: a report without a videoId is ignored too, and nothing starts', async () => { + queue.setCurrent(null) + + const body = (await (await post('report-failure', { errorCode: 101 })).json()) as Record<string, any> assert.equal(body.data.ignored, true) + assert.equal(player.getState().current, null) + assert.equal(getActivity().length, 0) }) await t.test('a malformed videoId is a 400 INVALID_VIDEO_ID', async () => { diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index e9c30e1..542ce69 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -184,6 +184,12 @@ test('sanitizeQueueItem()', async (t) => { assert.equal(sanitizeQueueItem({ videoId: A, title: '', requestedBy: 'x' }), null) assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: ' ' }), null) }) + + await t.test('a queue saved before the fallback flag knows its playlist tracks by the name', () => { + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'playlist' })?.isFallback, true) + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'Playlist' })?.isFallback, true) + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'bob' })?.isFallback, undefined) + }) }) test('initState()', async (t) => { @@ -214,7 +220,7 @@ test('initState()', async (t) => { }) await t.test('calling it again neither reloads the file nor registers a second saver', async () => { - queue.removeAt(0) + queue.removeByVideoId(B) initState() assert.deepEqual( From 8ca5785dd1cec0e66f2adf5bcf262a59a32fc8e2 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:55:26 +0700 Subject: [PATCH 12/22] Expand playlist loading and improve error handling Playlists: - load up to 250 tracks using playability guard filters - preserve the playlist's declared track count for the control panel - recognize YouTube Shorts up to three minutes Persistence: - consolidate file-backed list update logic Error handling: - preserve error causes in logs and API responses --- public/js/control/fallback.js | 16 +- public/locales/en.json | 1 + public/locales/ru.json | 1 + scripts/smoke-frontend.mjs | 1 + src/activity.ts | 31 ++- src/blocklist.ts | 74 ++++--- src/fallback.ts | 180 ++++++++++-------- src/persist.ts | 24 +++ src/playlists.ts | 47 ++--- src/routes/blocklist.ts | 2 +- src/state-file.ts | 28 ++- src/types.ts | 1 + src/youtube/client.ts | 78 ++++---- src/youtube/index.ts | 31 +-- tests/backend/fallback.refresh.test.ts | 164 ++++++++++++++-- tests/backend/fallback.restart.test.ts | 32 ++-- tests/backend/fallback.test.ts | 6 +- .../integrations/twitch/redemptions.test.ts | 2 +- tests/backend/player.blocklist.test.ts | 2 +- tests/backend/state-file.test.ts | 12 +- tests/backend/youtube/client.test.ts | 124 +++++++----- tests/backend/youtube/quota.test.ts | 2 +- tests/backend/youtube/request.test.ts | 11 ++ 23 files changed, 547 insertions(+), 323 deletions(-) diff --git a/public/js/control/fallback.js b/public/js/control/fallback.js index 2b6dfb8..39c506a 100644 --- a/public/js/control/fallback.js +++ b/public/js/control/fallback.js @@ -35,12 +35,20 @@ export function renderFallback() { return } + const itemCount = Number.isFinite(data?.itemCount) ? data.itemCount : null + setText( dom.fallbackInfo, - t('fallback.info', { - count: tracks.length, - datetime: formatDateTime(data.lastRefreshedAt) - }) + itemCount && itemCount > 0 + ? t('fallback.infoOf', { + count: tracks.length, + total: itemCount, + datetime: formatDateTime(data.lastRefreshedAt) + }) + : t('fallback.info', { + count: tracks.length, + datetime: formatDateTime(data.lastRefreshedAt) + }) ) renderFallbackTracks() renderStats() diff --git a/public/locales/en.json b/public/locales/en.json index 4d1d369..383d9a7 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -84,6 +84,7 @@ "repeat": "Repeat", "enable": "Enable playlist", "info": "{{count}} tracks ▪ updated {{datetime}}", + "infoOf": "{{count}} of {{total}} tracks ▪ updated {{datetime}}", "addToQueue": "Add to queue", "playNow": "Play now", "playing": "Playing", diff --git a/public/locales/ru.json b/public/locales/ru.json index 941b09d..5898cbb 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -84,6 +84,7 @@ "repeat": "Повтор", "enable": "Включить плейлист", "info": "{{count}} треков ▪ обновлено {{datetime}}", + "infoOf": "{{count}} из {{total}} треков ▪ обновлено {{datetime}}", "addToQueue": "Добавить в очередь", "playNow": "Воспроизвести сейчас", "playing": "Играет", diff --git a/scripts/smoke-frontend.mjs b/scripts/smoke-frontend.mjs index 79cdcc6..4714166 100644 --- a/scripts/smoke-frontend.mjs +++ b/scripts/smoke-frontend.mjs @@ -80,6 +80,7 @@ const routes = { activeVideoId: V('f'), lastRefreshedAt: Date.now(), sourceCount: 2, + itemCount: 5, shuffle: false, repeat: true, enabled: true diff --git a/src/activity.ts b/src/activity.ts index 0ad1be5..aa47bf0 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -1,12 +1,9 @@ import { ActivityEntry, ActivityReasonCode } from './types.js' -import { dataPath, createFileStore, loadList } from './persist.js' +import { dataPath, createListStore } from './persist.js' import { createLogger } from './logger.js' -import { createStore } from './store.js' const ACTIVITY_LIMIT = 100 -const store = createFileStore<ActivityEntry[]>(() => dataPath('activity.json')) - const isNullableString = (value: unknown): boolean => value === null || typeof value === 'string' function isActivityEntry(value: unknown): value is ActivityEntry { @@ -25,35 +22,29 @@ function isActivityEntry(value: unknown): value is ActivityEntry { ) } -// read from disk on first use, not when the module is imported; a file that grew past the limit is cut to it -let loaded: ActivityEntry[] | null = null -const entries = (): ActivityEntry[] => (loaded ??= loadList(store, isActivityEntry).slice(0, ACTIVITY_LIMIT)) - const log = createLogger('ACTIVITY') -const changes = createStore({ - read: entries, - topics: ['activity'], - save: () => store.scheduleSave(entries) +const entries = createListStore<ActivityEntry>({ + file: () => dataPath('activity.json'), + guard: isActivityEntry, + limit: ACTIVITY_LIMIT, + topics: ['activity'] }) export function logActivity(entry: Omit<ActivityEntry, 'at'>): void { - changes.update(() => { - const list = entries() + entries.update((list) => { list.unshift({ ...entry, at: Date.now() }) - if (list.length > ACTIVITY_LIMIT) { - list.length = ACTIVITY_LIMIT - } + if (list.length > ACTIVITY_LIMIT) list.length = ACTIVITY_LIMIT }) } export function getActivity(): ActivityEntry[] { - return [...entries()] + return entries.all() } export function clearActivity(): void { - changes.update(() => { - entries().length = 0 + entries.update((list) => { + list.length = 0 }) log.log('cleared') } diff --git a/src/blocklist.ts b/src/blocklist.ts index b398e29..8e733a8 100644 --- a/src/blocklist.ts +++ b/src/blocklist.ts @@ -1,7 +1,6 @@ -import { dataPath, createFileStore, loadList } from './persist.js' +import { dataPath, createListStore } from './persist.js' import { createLogger } from './logger.js' import { isValidVideoId } from './youtube/url.js' -import { createStore } from './store.js' import type { Blockable } from './types.js' export type BlockedTrack = { @@ -15,14 +14,6 @@ const BLOCKLIST_LIMIT = 1000 const TITLE_MAX_LENGTH = 200 const log = createLogger('BLOCKLIST') -const store = createFileStore<BlockedTrack[]>(() => dataPath('blocklist.json')) - -// a blocked track changes the queue view (it is skipped) and the list itself; the playlist view follows through 'state' -const changes = createStore({ - read: () => ensureLoaded().list, - topics: ['blocklist', 'state'], - save: () => store.scheduleSave(() => ensureLoaded().list) -}) function isBlockedTrack(value: unknown): value is BlockedTrack { if (typeof value !== 'object' || value === null) return false @@ -31,24 +22,27 @@ function isBlockedTrack(value: unknown): value is BlockedTrack { return isValidVideoId(v.videoId) && typeof v.title === 'string' && Number.isFinite(v.blockedAt) } -// read from disk on first use, not when the module is imported -let blocked: BlockedTrack[] | null = null -let blockedIds: Set<string> | null = null +const blocked = createListStore<BlockedTrack>({ + file: () => dataPath('blocklist.json'), + guard: isBlockedTrack, + limit: BLOCKLIST_LIMIT, + topics: ['blocklist', 'state'] +}) -function ensureLoaded(): { list: BlockedTrack[]; ids: Set<string> } { - if (!blocked || !blockedIds) { - blocked = loadList(store, isBlockedTrack) - blockedIds = new Set(blocked.map((t) => t.videoId)) - } - return { list: blocked, ids: blockedIds } +let ids: Set<string> | null = null + +function refreshIds(list: BlockedTrack[]): void { + ids = new Set(list.map((track) => track.videoId)) } +const idSet = (): Set<string> => (ids ??= new Set(blocked.all().map((track) => track.videoId))) + export function getBlockedTracks(): BlockedTrack[] { - return [...ensureLoaded().list] + return blocked.all() } export function isBlocked(videoId: string): boolean { - return ensureLoaded().ids.has(videoId) + return idSet().has(videoId) } // What the panel gets: every track says whether it is blocked, so the panel does not have to match lists @@ -57,35 +51,31 @@ export function markBlocked<T extends { videoId: string }>(tracks: T[]): Blockab } export function blockTrack(videoId: string, title: string): BlockedTrack { - const { list, ids } = ensureLoaded() - - changes.update(() => { - if (ids.has(videoId)) return - - const safeTitle = title.trim().slice(0, TITLE_MAX_LENGTH) || videoId - list.unshift({ videoId, title: safeTitle, blockedAt: Date.now() }) - ids.add(videoId) - - if (list.length > BLOCKLIST_LIMIT) { - const dropped = list.splice(BLOCKLIST_LIMIT) - for (const removed of dropped) ids.delete(removed.videoId) - log.warn(`Limit of ${BLOCKLIST_LIMIT} reached, ${dropped.length} oldest block(s) dropped`) + blocked.update((list) => { + if (!list.some((track) => track.videoId === videoId)) { + list.unshift({ videoId, title: title.trim().slice(0, TITLE_MAX_LENGTH) || videoId, blockedAt: Date.now() }) + + if (list.length > BLOCKLIST_LIMIT) { + const dropped = list.splice(BLOCKLIST_LIMIT) + log.warn(`Limit of ${BLOCKLIST_LIMIT} reached, ${dropped.length} oldest block(s) dropped`) + } } + refreshIds(list) }) - return list.find((t) => t.videoId === videoId)! + return blocked.all().find((track) => track.videoId === videoId)! } export function unblockTrack(videoId: string): boolean { let removed = false - changes.update(() => { - const { list, ids } = ensureLoaded() - if (!ids.has(videoId)) return - - blocked = list.filter((t) => t.videoId !== videoId) - ids.delete(videoId) - removed = true + blocked.update((list) => { + const index = list.findIndex((track) => track.videoId === videoId) + if (index >= 0) { + list.splice(index, 1) + removed = true + } + refreshIds(list) }) return removed diff --git a/src/fallback.ts b/src/fallback.ts index a013165..b4cf0f1 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -10,7 +10,7 @@ import { t } from './i18n.js' import { getSettings } from './settings.js' import { createStore } from './store.js' import { persistState } from './state-file.js' -import { createLogger } from './logger.js' +import { createLogger, describeError } from './logger.js' const log = createLogger('FALLBACK') @@ -23,52 +23,60 @@ export type FallbackSnapshot = { cursor: number playlistId: string | null lastRefreshedAt: number | null + itemCount: number | null } -let fallbackTracksById = new Map<string, Song>() -let fallbackOrder: string[] = [] -let fallbackCursor = -1 -let loadedFallbackPlaylistId: string | null = null -let lastFallbackRefreshAt: number | null = null +type Rotation = { + tracksById: Map<string, Song> + order: string[] + cursor: number + playlistId: string | null + lastRefreshedAt: number | null + itemCount: number | null + version: number +} + +const emptyRotation = (): Rotation => ({ tracksById: new Map(), order: [], cursor: -1, playlistId: null, lastRefreshedAt: null, itemCount: null, version: 0 }) -// Grows every time the set of playlist tracks is replaced, so the saved copy of the (large) track list -// is rewritten only then and not on every change of position -let sourceVersion = 0 +let rotation = emptyRotation() // the rotation is part of the playing state: the store saves and announces it when it changes -const rotation = createStore({ +const rotationStore = createStore({ read: () => ({ - tracks: sourceVersion, - order: fallbackOrder, - cursor: fallbackCursor, - playlistId: loadedFallbackPlaylistId, - lastRefreshedAt: lastFallbackRefreshAt + tracks: rotation.version, + order: rotation.order, + cursor: rotation.cursor, + playlistId: rotation.playlistId, + lastRefreshedAt: rotation.lastRefreshedAt, + itemCount: rotation.itemCount }), topics: ['state'], save: persistState }) -function setSourceTracks(tracks: Song[]): void { - fallbackTracksById = new Map(tracks.map((track) => [track.videoId, track])) - sourceVersion++ +function replaceSourceTracks(tracks: Song[]): void { + rotation = { + ...rotation, + tracksById: new Map(tracks.map((track) => [track.videoId, track])), + version: rotation.version + 1 + } } function resetRotation(): void { - setSourceTracks([]) - fallbackOrder = [] - fallbackCursor = -1 - loadedFallbackPlaylistId = null - lastFallbackRefreshAt = null + rotation = { ...emptyRotation(), version: rotation.version + 1 } } -export const getFallbackSourceVersion = (): number => sourceVersion - function findTrack(videoId: string): Song | undefined { - return fallbackTracksById.get(videoId) + return rotation.tracksById.get(videoId) } function activeFallbackVideoId(): string | null { - return fallbackOrder[fallbackCursor] ?? null + return rotation.order[rotation.cursor] ?? null +} + +function anchoredVideoId(): string | null { + const current = getCurrent() + return current?.isFallback ? current.videoId : activeFallbackVideoId() } function shuffle<T>(items: T[]): T[] { @@ -102,16 +110,16 @@ function toFallbackQueueItem(song: Song): QueueItem { function findNext(): { index: number; song: Song } | null { const { enabled, repeat } = getConfig().fallbackPlaylist - const total = fallbackOrder.length + const total = rotation.order.length if (!enabled) return null for (let step = 1; step <= total; step++) { - let index = fallbackCursor + step + let index = rotation.cursor + step if (index >= total) { if (!repeat) return null index %= total } - const song = findTrack(fallbackOrder[index]) + const song = findTrack(rotation.order[index]) if (song && !isBlocked(song.videoId)) return { index, song } } return null @@ -126,8 +134,8 @@ export function advanceFallback(): QueueItem | null { const next = findNext() if (!next) return null - rotation.update(() => { - fallbackCursor = next.index + rotationStore.update(() => { + rotation.cursor = next.index }) return toFallbackQueueItem(next.song) @@ -139,14 +147,14 @@ function fallbackView() { const { enabled, shuffle, repeat } = getConfig().fallbackPlaylist return { - playlistId: loadedFallbackPlaylistId, - lastRefreshedAt: lastFallbackRefreshAt, + playlistId: rotation.playlistId, + lastRefreshedAt: rotation.lastRefreshedAt, enabled, shuffle, repeat, - sourceCount: fallbackTracksById.size, - order: [...fallbackOrder], - blocked: fallbackOrder.map((videoId) => isBlocked(videoId)) + sourceCount: rotation.tracksById.size, + order: [...rotation.order], + blocked: rotation.order.map((videoId) => isBlocked(videoId)) } } @@ -189,7 +197,7 @@ function loadPlaylist(playlistId: string | null, rollbackTo: string | null): voi (error) => { // the playlist was replaced while it was loading: the failure is stale, the newer save wins if (getConfig().fallbackPlaylist.playlistId !== playlistId) return - log.error(`Playlist ${playlistId ?? 'none'} could not be loaded, ${rollbackTo ?? 'no playlist'} is kept instead`) + log.error(`Playlist ${playlistId ?? 'none'} could not be loaded, ${rollbackTo ?? 'no playlist'} is kept instead: ${describeError(error)}`) rollingBack = true try { updateConfig({ fallbackPlaylist: { playlistId: rollbackTo } }) @@ -215,24 +223,24 @@ onConfigChange((previous, next) => { if (after.playlistId !== before.playlistId) { // a rollback restores the id the rotation already holds, and one that was never loaded stays unloaded - if (!rollingBack && after.playlistId !== loadedFallbackPlaylistId) loadPlaylist(after.playlistId, before.playlistId) + if (!rollingBack && after.playlistId !== rotation.playlistId) loadPlaylist(after.playlistId, before.playlistId) } else if (after.shuffle !== before.shuffle) { reorderFallback(after.shuffle) } // what plays next depends on these, although the rotation itself does not change - if (after.enabled !== before.enabled || after.repeat !== before.repeat) rotation.announce() + if (after.enabled !== before.enabled || after.repeat !== before.repeat) rotationStore.announce() }) export async function refreshFallback(): Promise<FallbackStateResponse> { const playlistId = getConfig().fallbackPlaylist.playlistId if (!playlistId) { - rotation.update(() => resetRotation()) + rotationStore.update(() => resetRotation()) return getFallbackState() } - const { songs: newTracks, truncated } = await fetchPlaylistSongs(playlistId) + const { songs: newTracks, truncated, itemCount } = await fetchPlaylistSongs(playlistId) if (truncated) log.log(`Playlist was truncated due to page limit (${newTracks.length} tracks loaded)`) const config = getConfig() @@ -242,28 +250,28 @@ export async function refreshFallback(): Promise<FallbackStateResponse> { return getFallbackState() } - const playingId = getCurrent()?.isFallback ? getCurrent()!.videoId : activeFallbackVideoId() + const playingId = anchoredVideoId() - rotation.update(() => { - setSourceTracks(newTracks) - fallbackOrder = buildOrder(newTracks, config.fallbackPlaylist.shuffle, playingId) - fallbackCursor = playingId ? fallbackOrder.indexOf(playingId) : -1 - loadedFallbackPlaylistId = playlistId - lastFallbackRefreshAt = Date.now() + rotationStore.update(() => { + replaceSourceTracks(newTracks) + rotation.order = buildOrder(newTracks, config.fallbackPlaylist.shuffle, playingId) + rotation.cursor = playingId ? rotation.order.indexOf(playingId) : -1 + rotation.playlistId = playlistId + rotation.lastRefreshedAt = Date.now() + rotation.itemCount = itemCount }) - log.log(`Loaded ${fallbackTracksById.size} tracks from playlist ${playlistId}`) + log.log(`Loaded ${rotation.tracksById.size} tracks from playlist ${playlistId}`) return getFallbackState() } /** Re-orders the rotation for the given shuffle mode, keeping the playing track in place. Does not touch the config. */ export function reorderFallback(shuffleOn: boolean): void { - const current = getCurrent() - const activeId = current?.isFallback ? current.videoId : activeFallbackVideoId() + const activeId = anchoredVideoId() - rotation.update(() => { - fallbackOrder = buildOrder([...fallbackTracksById.values()], shuffleOn, activeId) - fallbackCursor = activeId ? fallbackOrder.indexOf(activeId) : -1 + rotationStore.update(() => { + rotation.order = buildOrder([...rotation.tracksById.values()], shuffleOn, activeId) + rotation.cursor = activeId ? rotation.order.indexOf(activeId) : -1 }) } @@ -284,7 +292,7 @@ export const toggleFallbackRepeat = (): FallbackStateResponse => toggleFallbackF export const toggleFallbackEnabled = (): FallbackStateResponse => toggleFallbackFlag('enabled') export function clearFallback(): FallbackStateResponse { - rotation.update(() => resetRotation()) + rotationStore.update(() => resetRotation()) updateConfig({ fallbackPlaylist: { playlistId: null } }) log.log('cleared') @@ -296,15 +304,16 @@ export function getFallbackState(): FallbackStateResponse { const current = getCurrent() return { - playlistId: loadedFallbackPlaylistId, - lastRefreshedAt: lastFallbackRefreshAt, + playlistId: rotation.playlistId, + lastRefreshedAt: rotation.lastRefreshedAt, enabled: config.fallbackPlaylist.enabled, shuffle: config.fallbackPlaylist.shuffle, repeat: config.fallbackPlaylist.repeat, - sourceCount: fallbackTracksById.size, + sourceCount: rotation.tracksById.size, + itemCount: rotation.itemCount, activeVideoId: current?.isFallback ? current.videoId : null, upNext: markBlocked( - fallbackOrder + rotation.order .map((videoId) => findTrack(videoId)) .filter((track): track is Song => track !== undefined && track !== null) ) @@ -312,7 +321,7 @@ export function getFallbackState(): FallbackStateResponse { } export function playFallbackTrackNow(videoId: string): QueueItem | null { - const index = fallbackOrder.indexOf(videoId) + const index = rotation.order.indexOf(videoId) if (index === -1) return null const song = findTrack(videoId) @@ -325,8 +334,8 @@ export function playFallbackTrackNow(videoId: string): QueueItem | null { // choosing a track to play right away skips whatever is playing now, open reward and all finishItem(getCurrent(), 'skipped') - rotation.update(() => { - fallbackCursor = index + rotationStore.update(() => { + rotation.cursor = index }) const item = toFallbackQueueItem(song) setCurrent(item) @@ -341,29 +350,22 @@ export function queueFallbackTrack(videoId: string): QueueItem | null { return addSong(song, fallbackRequester(), { bypassFilters: true, isFallback: true }).item } -export const getFallbackSourceTracks = (): Song[] => [...fallbackTracksById.values()] - -export function getFallbackProgress(): Omit<FallbackSnapshot, 'sourceTracks'> { +export function getFallbackSnapshot(): FallbackSnapshot & { sourceVersion: number } { return { - order: [...fallbackOrder], - cursor: fallbackCursor, - playlistId: loadedFallbackPlaylistId, - lastRefreshedAt: lastFallbackRefreshAt + sourceTracks: [...rotation.tracksById.values()], + order: [...rotation.order], + cursor: rotation.cursor, + playlistId: rotation.playlistId, + lastRefreshedAt: rotation.lastRefreshedAt, + itemCount: rotation.itemCount, + sourceVersion: rotation.version } } -// Puts the rotation position on `videoId` (the fallback track that is playing), if it is in the rotation -export function alignFallbackCursor(videoId: string): void { - const index = fallbackOrder.indexOf(videoId) - if (index >= 0) fallbackCursor = index -} - export function hydrateFallback(data: Partial<FallbackSnapshot> | undefined): void { if (!data) return const tracks = data.sourceTracks ?? [] - setSourceTracks(tracks) - const savedOrder = data.order ?? [] const known = new Set(tracks.map((track) => track.videoId)) const kept = [...new Set(savedOrder.filter((id) => known.has(id)))] @@ -375,10 +377,24 @@ export function hydrateFallback(data: Partial<FallbackSnapshot> | undefined): vo const order = [...kept, ...missing] const repaired = savedOrder.length !== order.length || order.some((id, index) => id !== savedOrder[index]) - fallbackOrder = order - fallbackCursor = repaired ? -1 : data.cursor ?? -1 - loadedFallbackPlaylistId = data.playlistId ?? null - lastFallbackRefreshAt = data.lastRefreshedAt ?? null + let cursor = repaired ? -1 : data.cursor ?? -1 + + // the restored position belongs under the track that is playing, if it is one of the rotation's + const current = getCurrent() + if (current?.isFallback) { + const index = order.indexOf(current.videoId) + if (index >= 0) cursor = index + } + + rotation = { + tracksById: new Map(tracks.map((track) => [track.videoId, track])), + order, + cursor, + playlistId: data.playlistId ?? null, + lastRefreshedAt: data.lastRefreshedAt ?? null, + itemCount: data.itemCount ?? null, + version: rotation.version + 1 + } // the hydrated state is what a panel that connects now would read: the view announcements start from it announcedView = fallbackView() diff --git a/src/persist.ts b/src/persist.ts index 5165e58..5548c30 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -4,6 +4,8 @@ import { setTimeout as sleep } from 'node:timers/promises' import { dirname, join } from 'node:path' import { getCacheDir, getDataDir } from './runtime.js' import { createLogger, describeError } from './logger.js' +import { createStore } from './store.js' +import type { Topic } from './state-events.js' const SAVE_DEBOUNCE_MS = 250 const SAVE_ATTEMPTS = 3 @@ -199,6 +201,28 @@ export function loadList<T>(store: { load: (defaults: T[]) => T[]; path: () => s return items } +/** + * A file-backed list: loaded lazily, changed only through update(), which compares the list before and after, + * saves it once and announces the topics. `limit` caps the list as it is read from disk; what a module does + * about its own limit while the list grows is that module's business + */ +export function createListStore<T>(options: { file: () => string; guard: (value: unknown) => value is T; limit?: number; topics: readonly Topic[] }) { + const store = createFileStore<T[]>(options.file) + const { guard, limit, topics } = options + let loaded: T[] | null = null + + // read from disk on first use, not when the module is imported + const list = (): T[] => (loaded ??= limit === undefined ? loadList(store, guard) : loadList(store, guard).slice(0, limit)) + + const changes = createStore({ read: list, topics, save: () => store.scheduleSave(list) }) + + return { + all: (): T[] => [...list()], + // in-place mutation: update() compares, saves and announces + update: (mutate: (list: T[]) => void): void => changes.update(() => mutate(list())) + } +} + export async function flushAllStores(): Promise<void> { await Promise.allSettled(activeStoreFlushers.map((flush) => flush())) } diff --git a/src/playlists.ts b/src/playlists.ts index 83a6d7f..e8f9875 100644 --- a/src/playlists.ts +++ b/src/playlists.ts @@ -1,5 +1,4 @@ -import { dataPath, createFileStore, loadList } from './persist.js' -import { createStore } from './store.js' +import { dataPath, createListStore } from './persist.js' import { onConfigChange } from './config.js' import { emit } from './state-events.js' @@ -11,8 +10,6 @@ export type SavedPlaylist = { addedAt: number } -const store = createFileStore<SavedPlaylist[]>(() => dataPath('playlists.json')) - // a saved playlist as the panel gets it: with the answer whether it is the one that is played export type PlaylistEntry = SavedPlaylist & { isActive: boolean } @@ -30,14 +27,10 @@ function isSavedPlaylist(value: unknown): value is SavedPlaylist { ) } -// read from disk on first use, not when the module is imported -let loaded: SavedPlaylist[] | null = null -const current = (): SavedPlaylist[] => (loaded ??= loadList(store, isSavedPlaylist)) - -const changes = createStore({ - read: current, - topics: ['playlists'], - save: () => store.scheduleSave(current) +const playlists = createListStore<SavedPlaylist>({ + file: () => dataPath('playlists.json'), + guard: isSavedPlaylist, + topics: ['playlists'] }) // the active mark of the saved playlists is derived from the config's playlist id: it moved, the list is read again @@ -46,22 +39,22 @@ onConfigChange((previous, next) => { }) export function getPlaylists(): SavedPlaylist[] { - return [...current()] + return playlists.all() } export function upsertPlaylist(meta: { id: string; title: string; thumbnail: string; itemCount: number }): SavedPlaylist { let saved!: SavedPlaylist - changes.update(() => { - const existing = current().find((p) => p.id === meta.id) + playlists.update((list) => { + const index = list.findIndex((playlist) => playlist.id === meta.id) + + saved = + index >= 0 + ? { ...list[index], title: meta.title, thumbnail: meta.thumbnail, itemCount: meta.itemCount } + : { ...meta, addedAt: Date.now() } - if (existing) { - saved = { ...existing, title: meta.title, thumbnail: meta.thumbnail, itemCount: meta.itemCount } - loaded = current().map((p) => (p.id === meta.id ? saved : p)) - } else { - saved = { ...meta, addedAt: Date.now() } - current().push(saved) - } + if (index >= 0) list[index] = saved + else list.push(saved) }) return saved @@ -70,11 +63,11 @@ export function upsertPlaylist(meta: { id: string; title: string; thumbnail: str export function removePlaylist(id: string): boolean { let removed = false - changes.update(() => { - if (current().some((p) => p.id === id)) { - loaded = current().filter((p) => p.id !== id) - removed = true - } + playlists.update((list) => { + const index = list.findIndex((playlist) => playlist.id === id) + if (index === -1) return + list.splice(index, 1) + removed = true }) return removed diff --git a/src/routes/blocklist.ts b/src/routes/blocklist.ts index 0c4c824..fdb330a 100644 --- a/src/routes/blocklist.ts +++ b/src/routes/blocklist.ts @@ -18,7 +18,7 @@ router.post('/', (req, res) => { checkVideoId(videoId) - const entry = blockTrack(videoId, typeof title === 'string' ? title.slice(0, 200) : videoId) + const entry = blockTrack(videoId, typeof title === 'string' ? title : videoId) const skipped = skipIfCurrent(videoId) ok(res, { entry, skipped }, 201) }) diff --git a/src/state-file.ts b/src/state-file.ts index 9ee32cb..0b88f6d 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -2,14 +2,7 @@ import { cachePath, createFileStore, dataPath } from './persist.js' import { QueueItem, Song } from './types.js' import { isValidPlaylistId, isValidVideoId } from './youtube/url.js' import { getQueue, getCurrent, hydrateQueue } from './queue.js' -import { - getFallbackProgress, - getFallbackSourceTracks, - getFallbackSourceVersion, - hydrateFallback, - FallbackSnapshot, - alignFallbackCursor -} from './fallback.js' +import { getFallbackSnapshot, hydrateFallback, FallbackSnapshot } from './fallback.js' import { createLogger, describeError } from './logger.js' export type StateFile = { @@ -102,13 +95,15 @@ function sanitizeFallback(raw: unknown, problems: string[]): FallbackSnapshot | const cursor = clean && Number.isInteger(rawCursor) && (rawCursor as number) >= -1 && (rawCursor as number) < order.length ? (rawCursor as number) : -1 const lastRefreshedAt = raw.lastRefreshedAt + const itemCount = raw.itemCount return { sourceTracks: [...tracks.values()], order, cursor, playlistId: isValidPlaylistId(raw.playlistId) ? raw.playlistId : null, - lastRefreshedAt: typeof lastRefreshedAt === 'number' && Number.isFinite(lastRefreshedAt) ? lastRefreshedAt : null + lastRefreshedAt: typeof lastRefreshedAt === 'number' && Number.isFinite(lastRefreshedAt) ? lastRefreshedAt : null, + itemCount: typeof itemCount === 'number' && Number.isFinite(itemCount) && itemCount >= 0 ? itemCount : null } } @@ -155,10 +150,11 @@ const tracksStore = createFileStore<TracksFile>(() => cachePath('fallback-tracks const log = createLogger('STATE') function stateSnapshot(): StateFile { + const { sourceVersion: _version, sourceTracks: _tracks, ...fallback } = getFallbackSnapshot() return { current: getCurrent(), queue: getQueue(), - fallback: getFallbackProgress() + fallback } } @@ -172,11 +168,11 @@ export function persistState(): void { store.scheduleSave(stateSnapshot) - const version = getFallbackSourceVersion() - if (version !== savedTracksVersion) { - savedTracksVersion = version + const { sourceVersion } = getFallbackSnapshot() + if (sourceVersion !== savedTracksVersion) { + savedTracksVersion = sourceVersion tracksStore.scheduleSave( - () => ({ sourceTracks: getFallbackSourceTracks() }), + () => ({ sourceTracks: getFallbackSnapshot().sourceTracks }), (error) => { savedTracksVersion = -1 // not on disk: written again with the next save log.error(`Failed to save the fallback tracks: ${describeError(error)}`) @@ -204,10 +200,10 @@ export function loadState(): void { for (const problem of problems) log.warn(problem) attempt('queue', () => hydrateQueue({ current, queue })) + // hydrateFallback() also puts the position under the track that is playing, if it is one of the rotation's attempt('fallback', () => hydrateFallback(fallback)) - if (current?.isFallback) attempt('fallback position', () => alignFallbackCursor(current.videoId)) - savedTracksVersion = tracks.length > 0 ? getFallbackSourceVersion() : -1 + savedTracksVersion = tracks.length > 0 ? getFallbackSnapshot().sourceVersion : -1 log.log('State loaded from disk') } diff --git a/src/types.ts b/src/types.ts index f493c86..66be0d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -173,6 +173,7 @@ export type FallbackStateResponse = FallbackPlaylist & { lastRefreshedAt: number | null upNext: Blockable<Song>[] sourceCount: number + itemCount: number | null activeVideoId: string | null } export type QueueRemoveResponse = { removed: QueueItem; state: StateResponse } diff --git a/src/youtube/client.ts b/src/youtube/client.ts index a88f505..3b7c6b0 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -1,6 +1,5 @@ import { getSecrets } from '../secrets.js' import { describeError } from '../logger.js' -import { getConfig } from '../config.js' import { Song, AppError, Config, FilterFailureReason } from '../types.js' import { VideoItem } from './types.js' import { isoDurationToSeconds } from './utils.js' @@ -13,7 +12,7 @@ export type PlaylistMeta = { itemCount: number } -export type PlaylistFetchResult = { songs: Song[]; truncated: boolean } +export type PlaylistFetchResult = { songs: Song[]; truncated: boolean; itemCount: number | null } type YouTubeErrorResponse = { error?: { @@ -25,12 +24,12 @@ type YouTubeErrorResponse = { type FilterRule = { reason: FilterFailureReason + guard?: boolean check: (song: Song, video: VideoItem, config: Config) => boolean - message: string params?: (config: Config) => Record<string, string | number> | undefined } -const SHORTS_MAX_DURATION_SECONDS = 60 +const SHORTS_MAX_DURATION_SECONDS = 180 export const VIDEO_DETAILS_PART = 'snippet,contentDetails,statistics,status,topicDetails' @@ -68,61 +67,71 @@ export function isMusicVideo(video: VideoItem): boolean { // 'live' or 'upcoming'; its duration is not known yet, so the duration rules do not apply to it const isUpcoming = (video: VideoItem): boolean => video.snippet?.liveBroadcastContent === 'upcoming' -const isLiveBroadcast = (video: VideoItem): boolean => Boolean(video.snippet?.liveBroadcastContent) && video.snippet!.liveBroadcastContent !== 'none' + +// 'none' means the video is not a broadcast; anything else is one +const isLiveBroadcast = (video: VideoItem): boolean => { + const live = video.snippet?.liveBroadcastContent + return live !== undefined && live !== 'none' +} + +const isNotPublic = (video: VideoItem): boolean => { + const privacy = video.status?.privacyStatus + return privacy !== undefined && privacy !== 'public' +} + +const isNotProcessed = (video: VideoItem): boolean => { + const upload = video.status?.uploadStatus + return upload !== undefined && upload !== 'processed' +} const FILTER_RULES: FilterRule[] = [ { reason: 'NOT_MUSIC', - check: (_s, v, c) => c.contentMode === 'music' && !isMusicVideo(v), - message: 'this video is not categorized as Music' + check: (_s, v, c) => c.contentMode === 'music' && !isMusicVideo(v) }, { reason: 'NOT_PUBLIC', - check: (_s, v) => Boolean(v.status?.privacyStatus) && v.status!.privacyStatus !== 'public', - message: 'this video is not public' + guard: true, + check: (_s, v) => isNotPublic(v) }, { reason: 'NOT_EMBEDDABLE', - check: (_s, v) => v.status?.embeddable === false, - message: 'this video cannot be embedded' + guard: true, + check: (_s, v) => v.status?.embeddable === false }, { reason: 'REGION_BLOCKED', - check: (_s, v, c) => !isAvailableInRegion(v, c.regionCode), - message: 'this track is not available in the configured region' + guard: true, + check: (_s, v, c) => !isAvailableInRegion(v, c.regionCode) }, { reason: 'AGE_RESTRICTED', - check: (_s, v) => v.contentDetails?.contentRating?.ytRating === 'ytAgeRestricted', - message: 'this video is age-restricted' + guard: true, + check: (_s, v) => v.contentDetails?.contentRating?.ytRating === 'ytAgeRestricted' }, { // a scheduled stream or a premiere has nothing to play yet, whatever the setting for live streams says reason: 'NOT_PLAYABLE', - check: (_s, v) => (Boolean(v.status?.uploadStatus) && v.status!.uploadStatus !== 'processed') || isUpcoming(v), - message: 'this video is not playable' + guard: true, + check: (_s, v) => isNotProcessed(v) || isUpcoming(v) }, { reason: 'IS_LIVE', - check: (_s, v, c) => !c.allowLiveStreams && isLiveBroadcast(v), - message: 'live streams are not allowed' + check: (_s, v, c) => !c.allowLiveStreams && isLiveBroadcast(v) }, { reason: 'VIEWS_TOO_LOW', check: (s, _v, c) => s.views < c.minViews, - message: 'this track does not have enough views', params: (c) => ({ min: c.minViews }) }, { reason: 'DURATION_OUT_OF_RANGE', check: (s, v, c) => !isLiveBroadcast(v) && (s.duration < c.minDurationSeconds || s.duration > c.maxDurationSeconds), - message: "this track's duration is outside the allowed range", params: (c) => ({ min: c.minDurationSeconds, max: c.maxDurationSeconds }) }, { reason: 'IS_SHORT', - check: (s, _v, c) => !c.allowShorts && isLikelyShort(s.duration), - message: 'shorts are not allowed' + check: (s, _v, c) => !c.allowShorts && isLikelyShort(s.duration) } ] @@ -223,19 +232,16 @@ function isLikelyShort(durationSeconds: number): boolean { return durationSeconds > 0 && durationSeconds <= SHORTS_MAX_DURATION_SECONDS } -export function getFilterFailureReason(song: Song, video: VideoItem): FilterFailureReason | null { - const config = getConfig() - return FILTER_RULES.find((rule) => rule.check(song, video, config))?.reason ?? null -} - -export function isValidSong(song: Song, video: VideoItem): boolean { - return getFilterFailureReason(song, video) === null -} - -export function throwFilterError(reason: FilterFailureReason, config: Config): never { - const rule = FILTER_RULES.find((r) => r.reason === reason) - if (!rule) throw new AppError('YOUTUBE_ERROR', `no filter rule registered for reason: ${reason}`) - throw new AppError(reason, rule.message, rule.params?.(config)) +/** + * The one filter check: the first rule a track fails becomes the error the API and the chat answer with. + * `guardOnly` limits the check to the guard rules: the streamer's own playlist answers to those alone, what + * the viewer rules would trim away there is the streamer's own choice. A rule's user-facing text lives in + * the locales and is looked up by the code, so the error carries only the params a rule's translation needs + */ +export function checkFilters(song: Song, video: VideoItem, config: Config, guardOnly = false): AppError | null { + const rules = guardOnly ? FILTER_RULES.filter((rule) => rule.guard) : FILTER_RULES + const rule = rules.find((rule) => rule.check(song, video, config)) + return rule ? new AppError(rule.reason, undefined, rule.params?.(config)) : null } const BATCH_SIZE = 50 diff --git a/src/youtube/index.ts b/src/youtube/index.ts index 85771cc..7946733 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -2,9 +2,7 @@ import { Song, AppError } from '../types.js' import { youtube, videoToSong, - isValidSong, - getFilterFailureReason, - throwFilterError, + checkFilters, fetchPlaylistMeta, PlaylistMeta, MUSIC_CATEGORY_ID, @@ -15,13 +13,13 @@ import { normalize } from './utils.js' import { getSearchCache, setSearchCache, getVideoCache, setVideoCache } from './cache.js' import { VideoItem, SearchItem, PlaylistItem } from './types.js' import { getConfig } from '../config.js' -import { createLogger } from '../logger.js' +import { createLogger, describeError } from '../logger.js' const pendingSearches = new Map<string, Promise<VideoItem[]>>() const pendingVideos = new Map<string, Promise<VideoItem | null>>() const pendingPlaylists = new Map<string, Promise<PlaylistFetchResult>>() const PLAYLIST_PAGE_SIZE = 50 -const MAX_PLAYLIST_PAGES = 3 +const MAX_PLAYLIST_PAGES = 5 const log = createLogger('YOUTUBE') @@ -30,7 +28,7 @@ async function withYouTubeErrorHandling<T>(label: string, fn: () => Promise<T>): return await fn() } catch (error) { if (error instanceof AppError) throw error - throw new AppError('YOUTUBE_ERROR', `${label} failed`) + throw new AppError('YOUTUBE_ERROR', `${label} failed: ${describeError(error)}`) } } @@ -57,8 +55,8 @@ export async function getVideoById(videoId: string, bypassFilters = false): Prom const song = videoToSong(video) if (bypassFilters) return song - const reason = getFilterFailureReason(song, video) - if (reason) throwFilterError(reason, getConfig()) + const failure = checkFilters(song, video, getConfig()) + if (failure) throw failure return song } @@ -78,17 +76,19 @@ export async function searchSongs(query: string, bypassFilters = false): Promise const cacheKey = searchCacheKey(normalizedQuery) const cached = getSearchCache(cacheKey) - if (cached !== undefined) return mapValidSongs(cached, bypassFilters) + if (cached !== undefined) return mapValidSongs(cached, bypassFilters ? 'bypass' : 'viewer') const trimmedQuery = query.trim() const videos = await dedupInFlight(pendingSearches, cacheKey, () => performSearch(trimmedQuery, cacheKey)) - return mapValidSongs(videos, bypassFilters) + return mapValidSongs(videos, bypassFilters ? 'bypass' : 'viewer') } -function mapValidSongs(videos: VideoItem[], bypassFilters = false): Song[] { +// which rules a list of videos is held against: the viewer rules, the guard rules alone (the streamer's own +// playlist answers to those: what the viewer rules would trim away is the streamer's own choice), or none at all +function mapValidSongs(videos: VideoItem[], scope: 'viewer' | 'guard' | 'bypass' = 'viewer'): Song[] { return videos .map((video) => ({ video, song: videoToSong(video) })) - .filter(({ video, song }) => bypassFilters || isValidSong(song, video)) + .filter(({ video, song }) => scope === 'bypass' || checkFilters(song, video, getConfig(), scope === 'guard') === null) .map(({ song }) => song) } @@ -139,7 +139,7 @@ export type { PlaylistMeta, PlaylistFetchResult } export async function fetchPlaylistSongs(playlistId: string): Promise<PlaylistFetchResult> { if (!playlistId) { - return { songs: [], truncated: false } + return { songs: [], truncated: false, itemCount: null } } return dedupInFlight(pendingPlaylists, playlistId, () => performPlaylistFetch(playlistId)) @@ -174,8 +174,9 @@ async function performPlaylistFetch(playlistId: string): Promise<PlaylistFetchRe } } - const songs = mapValidSongs(await fetchVideos([...ids])) + const meta = await fetchPlaylistMeta(playlistId) + const songs = mapValidSongs(await fetchVideos([...ids]), 'guard') log.log(`Playlist: ${songs.length} tracks loaded`) - return { songs, truncated } + return { songs, truncated, itemCount: meta?.itemCount ?? null } }) } diff --git a/tests/backend/fallback.refresh.test.ts b/tests/backend/fallback.refresh.test.ts index f1d47bd..58491e4 100644 --- a/tests/backend/fallback.refresh.test.ts +++ b/tests/backend/fallback.refresh.test.ts @@ -23,18 +23,22 @@ const song = (id: string, title = id) => ({ url: `https://youtu.be/${id}` }) -// stubs the YouTube "playlistItems" and "videos" endpoints that fetchPlaylistSongs() calls internally -function stubPlaylist(ids: string[]) { +// stubs the YouTube endpoints that fetchPlaylistSongs() calls internally; itemCount is the size the +// playlist declares about itself (often bigger than what it gives: private and deleted videos, the page limit) +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +function stubPlaylist(ids: string[], { itemCount = ids.length } = {}) { const realFetch = globalThis.fetch globalThis.fetch = async (input, init) => { const url = new URL(String(input instanceof Request ? input.url : input)) if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) if (url.pathname.endsWith('/playlistItems')) { - return new Response(JSON.stringify({ items: ids.map((id) => ({ snippet: { resourceId: { videoId: id } } })) }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) + return json({ items: ids.map((id) => ({ snippet: { resourceId: { videoId: id } } })) }) + } + + if (url.pathname.endsWith('/playlists')) { + return json({ items: [{ id: url.searchParams.get('id'), snippet: { title: 'Stub playlist' }, contentDetails: { itemCount } }] }) } // /videos @@ -46,7 +50,7 @@ function stubPlaylist(ids: string[]) { statistics: { viewCount: '1000000' }, status: { privacyStatus: 'public', embeddable: true, uploadStatus: 'processed' } })) - return new Response(JSON.stringify({ items }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + return json({ items }) } } @@ -123,14 +127,15 @@ test('refreshFallback() rebuilds rotation around the playing track', async (t) = await fallback.refreshFallback() // B is still in the playlist, the rest is reshuffled around it - const order = fallback.getFallbackProgress().order + const order = fallback.getFallbackSnapshot().order assert.equal(order[0], B, 'playing track stays first') assert.deepEqual(order.slice(1).sort(), [A, C, D, E], 'the rest are all present') }) }) test('the reaction to a saved playlist id', async (t) => { - await t.test('a load that fails restores the id that was there before', async () => { + await t.test('a load that fails restores the id that was there before and says why', async (t2) => { + const errors = t2.mock.method(console, 'error', () => {}) const realFetch = globalThis.fetch globalThis.fetch = async (input, init) => { const url = new URL(String(input instanceof Request ? input.url : input)) @@ -143,7 +148,13 @@ test('the reaction to a saved playlist id', async (t) => { await assert.rejects(fallback.settledConfigReaction(PL)) assert.equal(getConfig().fallbackPlaylist.playlistId, null, 'rolled back') - assert.equal(fallback.getFallbackProgress().playlistId, null, 'the rotation keeps what it had') + assert.equal(fallback.getFallbackSnapshot().playlistId, null, 'the rotation keeps what it had') + + // exactly one line, and it carries the cause, not only the ids + assert.equal(errors.mock.callCount(), 1) + const line = String(errors.mock.calls[0]?.arguments ?? '') + assert.ok(line.includes('could not be loaded'), line) + assert.ok(line.includes('refused'), line) } finally { globalThis.fetch = realFetch } @@ -228,7 +239,138 @@ test('the reaction to a saved playlist id', async (t) => { await new Promise((resolve) => setTimeout(resolve, 60)) assert.equal(getConfig().fallbackPlaylist.playlistId, other, 'the newer save is not rolled back') - assert.deepEqual(fallback.getFallbackProgress().order, [A, B], 'the newer playlist is loaded') + assert.deepEqual(fallback.getFallbackSnapshot().order, [A, B], 'the newer playlist is loaded') + } finally { + globalThis.fetch = realFetch + } + }) +}) + +test('what loading the playlist keeps and reports', async (t) => { + await t.test("what the viewer rules would trim away stays: the streamer's own playlist answers to the guard rules alone", async () => { + const realFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + + if (url.pathname.endsWith('/playlistItems')) { + return json({ items: [{ snippet: { resourceId: { videoId: A } } }] }) + } + if (url.pathname.endsWith('/playlists')) { + return json({ items: [{ id: PL, snippet: { title: 'Stub playlist' }, contentDetails: { itemCount: 1 } }] }) + } + + // a track the viewer rules refuse three times over: wrong category, longer than the maximum, barely any views + return json({ + items: [ + { + id: A, + snippet: { title: 'Stub', channelTitle: 'Channel', categoryId: '20', thumbnails: { medium: { url: '' } } }, + contentDetails: { duration: 'PT10M' }, + statistics: { viewCount: '42' }, + status: { privacyStatus: 'public', embeddable: true, uploadStatus: 'processed' } + } + ] + }) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 1, 'the track is loaded') + assert.deepEqual(state.upNext.map((track) => track.videoId), [A]) + } finally { + globalThis.fetch = realFetch + } + }) + + await t.test('a track the guard rules refuse is still dropped', async () => { + const realFetch = globalThis.fetch + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + + if (url.pathname.endsWith('/playlistItems')) { + return json({ items: [{ snippet: { resourceId: { videoId: A } } }] }) + } + if (url.pathname.endsWith('/playlists')) { + return json({ items: [{ id: PL, snippet: { title: 'Stub playlist' }, contentDetails: { itemCount: 1 } }] }) + } + + // a private track cannot be played by anyone, the streamer included + return json({ + items: [ + { + id: A, + snippet: { title: 'Stub', channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: '' } } }, + contentDetails: { duration: 'PT3M20S' }, + statistics: { viewCount: '1000000' }, + status: { privacyStatus: 'private', embeddable: true, uploadStatus: 'processed' } + } + ] + }) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 0, 'the private track is not loaded') + assert.deepEqual(state.upNext, []) + } finally { + globalThis.fetch = realFetch + } + }) + + await t.test('the declared size of the playlist travels with the state: the panel can show how much is playable', async () => { + stubPlaylist([A, B, C], { itemCount: 10 }) + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 3) + assert.equal(state.itemCount, 10) + }) + + await t.test('a huge playlist is capped at five pages of fifty, and says so with the declared size', async () => { + const realFetch = globalThis.fetch + let itemRequests = 0 + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + + if (url.pathname.endsWith('/playlistItems')) { + const page = ++itemRequests + const ids = Array.from({ length: 50 }, (_, i) => `pg${page}id${String(i).padStart(2, '0')}`.slice(0, 11)) + return json({ items: ids.map((id) => ({ snippet: { resourceId: { videoId: id } } })), nextPageToken: `page${page + 1}` }) + } + if (url.pathname.endsWith('/playlists')) { + return json({ items: [{ id: PL, snippet: { title: 'Stub playlist' }, contentDetails: { itemCount: 400 } }] }) + } + + const requestedIds = (url.searchParams.get('id') ?? '').split(',').filter(Boolean) + return json({ + items: requestedIds.map((id) => ({ + id, + snippet: { title: `Title ${id}`, channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: '' } } }, + contentDetails: { duration: 'PT3M20S' }, + statistics: { viewCount: '1000000' }, + status: { privacyStatus: 'public', embeddable: true, uploadStatus: 'processed' } + })) + }) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + assert.equal(itemRequests, 5, 'the page limit stops the walk') + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 250, 'five pages of fifty') + assert.equal(state.itemCount, 400, 'the declared size still tells the truth about the rest') } finally { globalThis.fetch = realFetch } diff --git a/tests/backend/fallback.restart.test.ts b/tests/backend/fallback.restart.test.ts index f17ad16..6c0abb4 100644 --- a/tests/backend/fallback.restart.test.ts +++ b/tests/backend/fallback.restart.test.ts @@ -88,18 +88,18 @@ test('after a restart the playlist goes on from where it was', async (t) => { assert.equal(queue.getCurrent()?.videoId, ids[9]) assert.equal(nextId(), ids[10]) - assert.deepEqual(fallback.getFallbackProgress().order, ids) - assert.equal(fallback.getFallbackProgress().cursor, 9) + assert.deepEqual(fallback.getFallbackSnapshot().order, ids) + assert.equal(fallback.getFallbackSnapshot().cursor, 9) }) await t.test('the playlist changed meanwhile (a track before the playing one removed, one added): the playing track stays, the next is still the next', async () => { const edited = [...ids.slice(0, 3), ...ids.slice(4), 'trkNEW00001'] await restart({ withTrackFile: false, playlist: edited }) - assert.equal(fallback.getFallbackProgress().order[fallback.getFallbackProgress().cursor], ids[9]) + assert.equal(fallback.getFallbackSnapshot().order[fallback.getFallbackSnapshot().cursor], ids[9]) assert.equal(nextId(), ids[10]) - assert.equal(fallback.getFallbackProgress().order.at(-1), 'trkNEW00001') - assert.equal(new Set(fallback.getFallbackProgress().order).size, fallback.getFallbackProgress().order.length) + assert.equal(fallback.getFallbackSnapshot().order.at(-1), 'trkNEW00001') + assert.equal(new Set(fallback.getFallbackSnapshot().order).size, fallback.getFallbackSnapshot().order.length) }) await t.test('a saved position that disagrees with the playing track follows the playing track', async () => { @@ -111,7 +111,7 @@ test('after a restart the playlist goes on from where it was', async (t) => { stubPlaylist(ids) loadState() - assert.equal(fallback.getFallbackProgress().cursor, 9) + assert.equal(fallback.getFallbackSnapshot().cursor, 9) assert.equal(nextId(), ids[10]) }) @@ -124,7 +124,7 @@ test('after a restart the playlist goes on from where it was', async (t) => { stubPlaylist(ids) loadState() - assert.equal(fallback.getFallbackProgress().cursor, 4) + assert.equal(fallback.getFallbackSnapshot().cursor, 4) assert.equal(nextId(), ids[5]) }) }) @@ -143,22 +143,22 @@ test('the rotation is reconciled with the track list it is saved next to', async lastRefreshedAt: 1 }) - assert.deepEqual(fallback.getFallbackProgress().order, trackIds) - assert.equal(fallback.getFallbackProgress().cursor, -1) + assert.deepEqual(fallback.getFallbackSnapshot().order, trackIds) + assert.equal(fallback.getFallbackSnapshot().cursor, -1) }) await t.test('a track missing from the rotation is appended, not lost: it plays again', () => { fallback.hydrateFallback({ sourceTracks: tracks, order: [trackIds[2], trackIds[1]], cursor: 1, playlistId: PLAYLIST, lastRefreshedAt: 1 }) - assert.deepEqual(fallback.getFallbackProgress().order, [trackIds[2], trackIds[1], trackIds[0]]) - assert.equal(fallback.getFallbackProgress().cursor, -1) + assert.deepEqual(fallback.getFallbackSnapshot().order, [trackIds[2], trackIds[1], trackIds[0]]) + assert.equal(fallback.getFallbackSnapshot().cursor, -1) }) await t.test('a rotation that already matches the track list is left alone, cursor included', () => { fallback.hydrateFallback({ sourceTracks: tracks, order: [...trackIds], cursor: 2, playlistId: PLAYLIST, lastRefreshedAt: 1 }) - assert.deepEqual(fallback.getFallbackProgress().order, trackIds) - assert.equal(fallback.getFallbackProgress().cursor, 2) + assert.deepEqual(fallback.getFallbackSnapshot().order, trackIds) + assert.equal(fallback.getFallbackSnapshot().cursor, 2) }) }) @@ -169,12 +169,12 @@ test('without any track the position is not used up', async (t) => { await t.test('nothing to play: the answer is null and the position is not used up', () => { fallback.hydrateFallback({ sourceTracks: [], order: ids, cursor: 9, playlistId: PLAYLIST, lastRefreshedAt: 1 }) // the rotation without its tracks is repaired to an empty one, so the position starts over - assert.deepEqual(fallback.getFallbackProgress().order, []) - assert.equal(fallback.getFallbackProgress().cursor, -1) + assert.deepEqual(fallback.getFallbackSnapshot().order, []) + assert.equal(fallback.getFallbackSnapshot().cursor, -1) assert.equal(fallback.advanceFallback(), null) assert.equal(fallback.peekNextFallbackTrack(), null) - assert.equal(fallback.getFallbackProgress().cursor, -1, 'advancing an empty rotation consumes nothing') + assert.equal(fallback.getFallbackSnapshot().cursor, -1, 'advancing an empty rotation consumes nothing') }) await t.test('and once the tracks are there the rotation goes on from the same place', () => { diff --git a/tests/backend/fallback.test.ts b/tests/backend/fallback.test.ts index 6a08964..80b6594 100644 --- a/tests/backend/fallback.test.ts +++ b/tests/backend/fallback.test.ts @@ -33,7 +33,7 @@ beforeEach(() => { updateConfig({ fallbackPlaylist: { shuffle: false } }) }) -const order = () => fallback.getFallbackProgress().order +const order = () => fallback.getFallbackSnapshot().order test('reorderFallback()', async (t) => { await t.test('re-orders the rotation without touching the stored shuffle flag', () => { @@ -92,8 +92,8 @@ test('state persistence hooks', async (t) => { countNotifications(() => fallback.clearFallback()), 1 ) - assert.deepEqual(fallback.getFallbackProgress().order, []) - assert.equal(fallback.getFallbackProgress().playlistId, null) + assert.deepEqual(fallback.getFallbackSnapshot().order, []) + assert.equal(fallback.getFallbackSnapshot().playlistId, null) }) }) diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index fc033c0..1087477 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -259,7 +259,7 @@ test('handleChannelPointsRedemption()', async (t) => { { id: A, snippet: { title: `Song ${A}`, channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: 'https://img/x.jpg' } } }, - contentDetails: { duration: 'PT3M' }, + contentDetails: { duration: 'PT3M20S' }, statistics: { viewCount: '1000000' } } ] diff --git a/tests/backend/player.blocklist.test.ts b/tests/backend/player.blocklist.test.ts index e5f4f67..c92d95f 100644 --- a/tests/backend/player.blocklist.test.ts +++ b/tests/backend/player.blocklist.test.ts @@ -40,7 +40,7 @@ beforeEach(() => { setFallback([]) }) -const cursor = () => fallback.getFallbackProgress().cursor +const cursor = () => fallback.getFallbackSnapshot().cursor const playing = (id: string) => queue.setCurrent({ ...song(id), requestedBy: 'viewer' }) test('moveToNext()', async (t) => { diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index 542ce69..4fee42e 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -34,7 +34,7 @@ const goodState = () => ({ current: item(A), queue: [item(B), item(C)], settings: { showVideo: true, position: 'top-left', locale: 'ru' }, - fallback: { sourceTracks: [song(F1), song(F2)], order: [F1, F2], cursor: 0, playlistId: 'PLstate0000001', lastRefreshedAt: 5 } + fallback: { sourceTracks: [song(F1), song(F2)], order: [F1, F2], cursor: 0, playlistId: 'PLstate0000001', lastRefreshedAt: 5, itemCount: 12 } }) test('sanitizeState()', async (t) => { @@ -60,9 +60,19 @@ test('sanitizeState()', async (t) => { ) assert.equal(result.current?.videoId, A) assert.equal(result.fallback?.playlistId, 'PLstate0000001') + assert.equal(result.fallback?.itemCount, 12, 'the declared playlist size travels with the state') assert.ok(result.problems.some((problem) => problem.includes('dropped 4 invalid'))) }) + await t.test('an unusable declared playlist size becomes unknown, not a lie', () => { + const state = goodState() + state.fallback.itemCount = 'many' as never + const result = sanitizeState(state) + + assert.equal(result.fallback?.itemCount, null) + assert.deepEqual(result.problems, []) + }) + await t.test('duplicates are dropped, including a queued copy of the track that is playing', () => { const result = sanitizeState({ ...goodState(), queue: [item(A), item(B), item(B, 'other')] }) diff --git a/tests/backend/youtube/client.test.ts b/tests/backend/youtube/client.test.ts index ef3ac8e..b3a592d 100644 --- a/tests/backend/youtube/client.test.ts +++ b/tests/backend/youtube/client.test.ts @@ -4,13 +4,16 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { VideoItem } from '../../../src/youtube/types.js' -import { AppError, type Song } from '../../../src/types.js' +import { type Song, type FilterFailureReason } from '../../../src/types.js' // updateConfig() saves to data/config.json under the working directory: use a throwaway one, not the real one process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { isAvailableInRegion, getFilterFailureReason, throwFilterError, isMusicVideo } = await import('../../../src/youtube/client.js') -const { updateConfig } = await import('../../../src/config.js') +const { isAvailableInRegion, checkFilters, isMusicVideo } = await import('../../../src/youtube/client.js') +const { updateConfig, getConfig } = await import('../../../src/config.js') + +// the reason the filters would answer with, or null when the track passes them all +const reasonOf = (song: Song, video: VideoItem): FilterFailureReason | null => checkFilters(song, video, getConfig())?.code ?? null function baseSong(overrides: Partial<Song> = {}): Song { return { @@ -73,38 +76,65 @@ test('isAvailableInRegion', async (t) => { }) }) -test('getFilterFailureReason', async (t) => { +test('checkFilters', async (t) => { await t.test('permissive config, clean video: no rejection reason', () => { updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '' }) - assert.equal(getFilterFailureReason(baseSong(), baseVideo()), null) + assert.equal(reasonOf(baseSong(), baseVideo()), null) }) await t.test('wrong category: NOT_MUSIC', () => { updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '' }) const video = baseVideo({ snippet: { categoryId: '20' } }) - assert.equal(getFilterFailureReason(baseSong(), video), 'NOT_MUSIC') + assert.equal(reasonOf(baseSong(), video), 'NOT_MUSIC') }) await t.test('not embeddable: NOT_EMBEDDABLE', () => { updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '' }) const video = baseVideo({ status: { embeddable: false } }) - assert.equal(getFilterFailureReason(baseSong(), video), 'NOT_EMBEDDABLE') + assert.equal(reasonOf(baseSong(), video), 'NOT_EMBEDDABLE') }) await t.test('duration outside configured range: DURATION_OUT_OF_RANGE', () => { updateConfig({ minViews: 0, minDurationSeconds: 60, maxDurationSeconds: 120, regionCode: '' }) - assert.equal(getFilterFailureReason(baseSong({ duration: 10 }), baseVideo()), 'DURATION_OUT_OF_RANGE') + assert.equal(reasonOf(baseSong({ duration: 10 }), baseVideo()), 'DURATION_OUT_OF_RANGE') }) await t.test('views below configured minimum: VIEWS_TOO_LOW', () => { updateConfig({ minViews: 5_000_000, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '' }) - assert.equal(getFilterFailureReason(baseSong({ views: 100 }), baseVideo()), 'VIEWS_TOO_LOW') + assert.equal(reasonOf(baseSong({ views: 100 }), baseVideo()), 'VIEWS_TOO_LOW') }) await t.test('blocked in configured region: REGION_BLOCKED', () => { updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: 'RU' }) const video = baseVideo({ contentDetails: { regionRestriction: { blocked: ['RU'] } } }) - assert.equal(getFilterFailureReason(baseSong(), video), 'REGION_BLOCKED') + assert.equal(reasonOf(baseSong(), video), 'REGION_BLOCKED') + }) + + await t.test('a track of up to three minutes is refused as a Short when they are not allowed', () => { + updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '', allowShorts: false }) + + assert.equal(reasonOf(baseSong({ duration: 120 }), baseVideo()), 'IS_SHORT') + + updateConfig({ allowShorts: true }) + assert.equal(reasonOf(baseSong({ duration: 120 }), baseVideo()), null) + updateConfig({ allowShorts: false }) + }) + + await t.test('guardOnly: the guard rules alone say whether a track can be played at all', () => { + const config = { contentMode: 'music', minViews: 100_000, minDurationSeconds: 60, maxDurationSeconds: 480, allowShorts: false, regionCode: 'RU' } + const refusedByViewerRules = { ...baseSong({ duration: 120, views: 5 }) } + const video = baseVideo({ snippet: { categoryId: '20' } }) + + // the viewer rules refuse it four times over + assert.equal(reasonOf(refusedByViewerRules, video), 'NOT_MUSIC') + + // the guard scope sees none of that: the track can be played, so the streamer can keep it in the playlist + const failure = checkFilters(refusedByViewerRules, video, config, true) + assert.equal(failure, null) + + // what the guard rules refuse, the guard scope refuses too + const private_ = baseVideo({ status: { privacyStatus: 'private', embeddable: true } }) + assert.equal(checkFilters(refusedByViewerRules, private_, config, true)?.code, 'NOT_PUBLIC') }) }) @@ -119,14 +149,14 @@ test('contentMode', async (t) => { assert.equal(isMusicVideo(baseVideo()), true) assert.equal(isMusicVideo(clip), true) assert.equal(isMusicVideo(game), false) - assert.equal(getFilterFailureReason(baseSong(), clip), null) - assert.equal(getFilterFailureReason(baseSong(), game), 'NOT_MUSIC') + assert.equal(reasonOf(baseSong(), clip), null) + assert.equal(reasonOf(baseSong(), game), 'NOT_MUSIC') }) await t.test('any: the music check is switched off', () => { updateConfig({ ...open, contentMode: 'any' }) - assert.equal(getFilterFailureReason(baseSong(), game), null) + assert.equal(reasonOf(baseSong(), game), null) updateConfig({ contentMode: 'music' }) }) }) @@ -138,12 +168,12 @@ test('live streams', async (t) => { await t.test('refused as IS_LIVE (not as a bad duration) when live streams are not allowed', () => { updateConfig({ ...open, allowLiveStreams: false }) - assert.equal(getFilterFailureReason(stream, live), 'IS_LIVE') + assert.equal(reasonOf(stream, live), 'IS_LIVE') }) await t.test('accepted when allowed: the duration rules do not apply to a stream', () => { updateConfig({ ...open, allowLiveStreams: true }) - assert.equal(getFilterFailureReason(stream, live), null) + assert.equal(reasonOf(stream, live), null) updateConfig({ allowLiveStreams: false }) }) }) @@ -155,51 +185,53 @@ test('scheduled streams', async (t) => { await t.test('an upcoming stream or premiere is refused even when live streams are allowed: there is nothing to play yet', () => { updateConfig({ ...open, allowLiveStreams: true }) - assert.equal(getFilterFailureReason(baseSong({ duration: 0 }), upcoming), 'NOT_PLAYABLE') + assert.equal(reasonOf(baseSong({ duration: 0 }), upcoming), 'NOT_PLAYABLE') }) await t.test('a stream that is on now is still accepted when live streams are allowed', () => { updateConfig({ ...open, allowLiveStreams: true }) - assert.equal(getFilterFailureReason(baseSong({ duration: 0 }), live), null) + assert.equal(reasonOf(baseSong({ duration: 0 }), live), null) updateConfig({ allowLiveStreams: false }) }) }) -test('throwFilterError', async (t) => { - await t.test('REGION_BLOCKED does not leak the configured region', () => { - const config = { regionCode: 'RU' } as Parameters<typeof throwFilterError>[1] - assert.throws( - () => throwFilterError('REGION_BLOCKED', config), - (error: unknown) => error instanceof AppError && error.code === 'REGION_BLOCKED' && error.params === undefined - ) +test('a rule carries the params its translation needs', async (t) => { + await t.test('REGION_BLOCKED carries no params: the region stays private', () => { + const config = { regionCode: 'RU' } as Parameters<typeof checkFilters>[2] + const video = baseVideo({ contentDetails: { regionRestriction: { blocked: ['RU'] } } }) + const failure = checkFilters(baseSong(), video, config) + + assert.equal(failure?.code, 'REGION_BLOCKED') + assert.equal(failure?.params, undefined) }) await t.test('DURATION_OUT_OF_RANGE carries the configured bounds', () => { - const config = { minDurationSeconds: 60, maxDurationSeconds: 480 } as Parameters<typeof throwFilterError>[1] - assert.throws( - () => throwFilterError('DURATION_OUT_OF_RANGE', config), - (error: unknown) => error instanceof AppError && error.params?.min === 60 && error.params?.max === 480 - ) + const config = { minDurationSeconds: 60, maxDurationSeconds: 480 } as Parameters<typeof checkFilters>[2] + const failure = checkFilters(baseSong({ duration: 10 }), baseVideo(), config) + + assert.equal(failure?.code, 'DURATION_OUT_OF_RANGE') + assert.equal(failure?.params?.min, 60) + assert.equal(failure?.params?.max, 480) }) await t.test('VIEWS_TOO_LOW carries the configured minimum', () => { - const config = { minViews: 10_000 } as Parameters<typeof throwFilterError>[1] - assert.throws( - () => throwFilterError('VIEWS_TOO_LOW', config), - (error: unknown) => error instanceof AppError && error.params?.min === 10_000 - ) - }) - - await t.test('NOT_MUSIC and NOT_EMBEDDABLE throw with no params', () => { - const config = {} as Parameters<typeof throwFilterError>[1] - assert.throws( - () => throwFilterError('NOT_MUSIC', config), - (error: unknown) => error instanceof AppError && error.code === 'NOT_MUSIC' - ) - assert.throws( - () => throwFilterError('NOT_EMBEDDABLE', config), - (error: unknown) => error instanceof AppError && error.code === 'NOT_EMBEDDABLE' - ) + const config = { minViews: 10_000 } as Parameters<typeof checkFilters>[2] + const failure = checkFilters(baseSong({ views: 100 }), baseVideo(), config) + + assert.equal(failure?.code, 'VIEWS_TOO_LOW') + assert.equal(failure?.params?.min, 10_000) + }) + + await t.test('NOT_MUSIC and NOT_EMBEDDABLE carry no params, and the code is the whole message', () => { + const music = { contentMode: 'music' } as Parameters<typeof checkFilters>[2] + const notMusic = checkFilters(baseSong(), baseVideo({ snippet: { categoryId: '20' } }), music) + const notEmbeddable = checkFilters(baseSong(), baseVideo({ status: { embeddable: false } }), music) + + assert.equal(notMusic?.code, 'NOT_MUSIC') + assert.equal(notMusic?.params, undefined) + assert.equal(notMusic?.message, 'NOT_MUSIC') + assert.equal(notEmbeddable?.code, 'NOT_EMBEDDABLE') + assert.equal(notEmbeddable?.params, undefined) }) }) diff --git a/tests/backend/youtube/quota.test.ts b/tests/backend/youtube/quota.test.ts index 7d1084e..8f752f3 100644 --- a/tests/backend/youtube/quota.test.ts +++ b/tests/backend/youtube/quota.test.ts @@ -60,7 +60,7 @@ const video = (videoId: string, views: number) => ({ categoryId: '10', thumbnails: { medium: { url: 'https://img/x.jpg' } } }, - contentDetails: { duration: 'PT3M' }, + contentDetails: { duration: 'PT3M20S' }, statistics: { viewCount: String(views) }, status: { privacyStatus: 'public', embeddable: true } }) diff --git a/tests/backend/youtube/request.test.ts b/tests/backend/youtube/request.test.ts index 9f2e332..10e9592 100644 --- a/tests/backend/youtube/request.test.ts +++ b/tests/backend/youtube/request.test.ts @@ -7,6 +7,7 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const { youtube } = await import('../../../src/youtube/client.js') +const { getVideoById } = await import('../../../src/youtube/index.js') const { updateSecrets } = await import('../../../src/secrets.js') updateSecrets({ youtubeApiKey: 'test-key' }) @@ -41,6 +42,16 @@ test('youtube() requests', async (t) => { assert.equal(calls, 1) }) + await t.test('a response of an unexpected shape keeps its cause in the thrown message', async () => { + // a 200 whose body is not an object breaks the mapping far from the request: the wrapper must say what broke + stub(json(200, null)) + await assert.rejects( + getVideoById('aaaaaaaaaaa'), + (error: { code?: string; message?: string }) => error.code === 'YOUTUBE_ERROR' && /YouTube API failed: TypeError:/.test(error.message ?? '') + ) + }) + + // marks the in-memory quota flag exhausted for the rest of this file: keep this subtest last await t.test('a quota error is not repeated and is reported as the quota error', async () => { stub(json(403, { error: { message: 'q', errors: [{ reason: 'quotaExceeded' }] } })) await assert.rejects(youtube('videos', { id: 'x' }), (error: { code?: string }) => error.code === 'YOUTUBE_QUOTA') From f9540e811907578ba7a75eeea3c568e417a80450 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:40:12 +0700 Subject: [PATCH 13/22] Unify fallback track labeling and player lifecycle handling Fallback tracks: - store a stable marker for fallback tracks - display the localized playlist name consistently across the player, queue, overlay, chat replies, and activity journal Refactoring: - extract song-request handling from the queue module - centralize player track completion --- public/js/control/player.js | 4 +- public/js/control/views/queue.js | 6 +- public/js/i18n.js | 4 + public/js/overlay.js | 4 +- src/chat-replies.ts | 3 +- src/fallback.ts | 11 ++- src/finish.ts | 8 +- src/integrations/twitch/index.ts | 3 +- src/player.ts | 50 +++++++------ src/queue.ts | 74 ++++--------------- src/request.ts | 53 +++++++++++++ src/routes/queue.ts | 3 +- src/state-file.ts | 4 +- tests/backend/chat-replies.test.ts | 19 ++++- tests/backend/fallback.test.ts | 10 +-- tests/backend/finish.test.ts | 24 +++++- .../integrations/twitch/chat-commands.test.ts | 3 +- tests/backend/player.test.ts | 15 ++++ tests/backend/queue.user-limit.test.ts | 7 ++ tests/backend/state-file.test.ts | 9 ++- tests/backend/youtube/client.test.ts | 2 +- tests/frontend/control/queue.test.js | 15 ++++ tests/frontend/overlay.test.js | 7 ++ 23 files changed, 221 insertions(+), 117 deletions(-) create mode 100644 src/request.ts diff --git a/public/js/control/player.js b/public/js/control/player.js index 5107548..7697304 100644 --- a/public/js/control/player.js +++ b/public/js/control/player.js @@ -3,7 +3,7 @@ import { PLAY_ICON, PAUSE_ICON } from '../icons.js' import { state } from './state.js' import { dom } from './dom.js' import { log } from './log.js' -import { t, getCurrentLocale } from '../i18n.js' +import { t, getCurrentLocale, requesterName } from '../i18n.js' import { loadYouTubeApi } from '../youtube-api.js' let player = null @@ -25,7 +25,7 @@ export function renderCurrent() { setText(dom.currentChannel, current.channelTitle) setText(dom.currentDuration, formatDuration(current.duration)) setText(dom.currentViews, t('player.views', { count: formatViews(current.views, getCurrentLocale()) })) - setText(dom.currentRequester, `@${current.requestedBy}`) + setText(dom.currentRequester, requesterName(current)) } export function renderNext() { diff --git a/public/js/control/views/queue.js b/public/js/control/views/queue.js index abab167..23eab63 100644 --- a/public/js/control/views/queue.js +++ b/public/js/control/views/queue.js @@ -1,12 +1,12 @@ import { escapeHtml, formatDuration } from '../../shared.js' import { DELETE_ICON } from '../../icons.js' -import { t } from '../../i18n.js' +import { t, requesterName } from '../../i18n.js' import { dom } from '../dom.js' import { blockTrackItem, createListView, row, rowMenu, statusPill, unblockTrackItem } from './primitives.js' export const queueView = createListView(dom.queueListWrapper, { getKey: (items) => - items.map((item) => [item.videoId, item.requestedBy, item.title, item.thumbnail, item.duration, item.isBlocked].join(':')).join('|'), + items.map((item) => [item.videoId, requesterName(item), item.title, item.thumbnail, item.duration, item.isBlocked].join(':')).join('|'), renderRow: (item, index) => row({ index: index + 1, @@ -16,7 +16,7 @@ export const queueView = createListView(dom.queueListWrapper, { extra: ` ${item.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : ''} <span class="column-requested-by text-sm text-green truncate"> - @${escapeHtml(item.requestedBy)} + ${escapeHtml(requesterName(item))} </span> `, meta: formatDuration(item.duration), diff --git a/public/js/i18n.js b/public/js/i18n.js index cee09e6..9f666e2 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -60,6 +60,10 @@ export function getCurrentLocale() { return currentLocale } +export function requesterName(item) { + return item?.isFallback ? t('fallback.requester') : item?.requestedBy ? `@${item.requestedBy}` : '' +} + export async function initI18n(locale = DEFAULT_LOCALE) { await loadTranslations(locale) document.documentElement.lang = currentLocale diff --git a/public/js/overlay.js b/public/js/overlay.js index d851560..0d22e61 100644 --- a/public/js/overlay.js +++ b/public/js/overlay.js @@ -1,5 +1,5 @@ import { $, formatDuration, createLogger, getErrorMessage, show, setClass, setText } from './shared.js' -import { initI18n, t, getCurrentLocale } from './i18n.js' +import { initI18n, t, getCurrentLocale, requesterName } from './i18n.js' import { loadYouTubeApi } from './youtube-api.js' import { claimPlayback } from './playback-lock.js' import { watch } from './sse.js' @@ -76,7 +76,7 @@ function renderCurrent() { dom.badge.dataset.position = settings.position dom.currentThumbnail.src = currentState.current.thumbnail setText(dom.currentTitle, currentState.current.title) - setText(dom.currentRequester, `@${currentState.current.requestedBy}`) + setText(dom.currentRequester, requesterName(currentState.current)) if (currentState.nextTrack) { setText(dom.nextTitle, currentState.nextTrack.title) diff --git a/src/chat-replies.ts b/src/chat-replies.ts index 5a1e43b..09a571f 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -12,7 +12,8 @@ export function buildNowPlayingMessage(state: PlayerState, locale: string): stri return t(locale, 'chat.nothingPlaying') } - const base = t(locale, 'chat.nowPlayingWithRequester', { title: state.current.title, requestedBy: state.current.requestedBy }) + const requester = state.current.isFallback ? t(locale, 'fallback.requester') : state.current.requestedBy + const base = t(locale, 'chat.nowPlayingWithRequester', { title: state.current.title, requestedBy: requester }) const pausedSuffix = state.isPaused ? t(locale, 'chat.pausedSuffix') : '' return base + pausedSuffix } diff --git a/src/fallback.ts b/src/fallback.ts index b4cf0f1..cc55063 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -6,16 +6,15 @@ import { addSong, getCurrent, setCurrent } from './queue.js' import { finishItem } from './finish.js' import { on, emit } from './state-events.js' import { isBlocked, markBlocked } from './blocklist.js' -import { t } from './i18n.js' -import { getSettings } from './settings.js' import { createStore } from './store.js' import { persistState } from './state-file.js' import { createLogger, describeError } from './logger.js' const log = createLogger('FALLBACK') -// the name shown wherever the playlist "requests" a track; the queue counts such tracks as nobody's -const fallbackRequester = (): string => t(getSettings().locale, 'fallback.requester') +// The stable marker every playlist track carries in `requestedBy`. The localized name is not stored: it is +// put in wherever the track is shown, so it follows the reader's language however long the track sits there +const FALLBACK_REQUESTER = 'playlist' export type FallbackSnapshot = { sourceTracks: Song[] @@ -103,7 +102,7 @@ function buildOrder(tracks: Song[], shuffleOn: boolean, currentVideoId: string | function toFallbackQueueItem(song: Song): QueueItem { return { ...song, - requestedBy: fallbackRequester(), + requestedBy: FALLBACK_REQUESTER, isFallback: true } } @@ -347,7 +346,7 @@ export function queueFallbackTrack(videoId: string): QueueItem | null { const song = findTrack(videoId) if (!song) return null - return addSong(song, fallbackRequester(), { bypassFilters: true, isFallback: true }).item + return addSong(song, FALLBACK_REQUESTER, { bypassFilters: true, isFallback: true }).item } export function getFallbackSnapshot(): FallbackSnapshot & { sourceVersion: number } { diff --git a/src/finish.ts b/src/finish.ts index 9d9f981..ad42f97 100644 --- a/src/finish.ts +++ b/src/finish.ts @@ -1,5 +1,7 @@ import type { ActivityReasonCode, ActivityStatus, FailureReason, QueueItem } from './types.js' import { logActivity } from './activity.js' +import { t } from './i18n.js' +import { getSettings } from './settings.js' import { createLogger, describeError } from './logger.js' type Tracked = NonNullable<QueueItem['channelPointsRedemption']> @@ -20,10 +22,12 @@ export function registerRedemptionHandler(next: typeof handler): void { type ReasonParams = Record<string, string | number> // the journal entry a track writes when it leaves without playing through: who asked for it, what it was, -// and why it never played. `query` keeps what identifies the track (the requester typed a title, not a url) +// and why it never played. `query` keeps what identifies the track (the requester typed a title, not a url). +// The entry is a record of the moment: a playlist track, which carries only a marker, is named in the +// language of that moment function journal(item: QueueItem, status: ActivityStatus, reasonCode: ActivityReasonCode, reasonParams?: ReasonParams): void { logActivity({ - requestedBy: item.requestedBy, + requestedBy: item.isFallback ? t(getSettings().locale, 'fallback.requester') : item.requestedBy, query: item.url, title: item.title, videoId: item.videoId, diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 468fe64..7a6efbe 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -11,7 +11,8 @@ import type { TwitchChatPermission } from './types.js' import { clearTwitchAuthorization, clearTwitchOAuthState, getPublicSecretsView, getSecrets, updateTwitchOAuthState } from '../../secrets.js' -import { detachChannelPointsRedemptions, requestSong } from '../../queue.js' +import { detachChannelPointsRedemptions } from '../../queue.js' +import { requestSong } from '../../request.js' import { buildNowPlayingMessage, buildQueueMessage, diff --git a/src/player.ts b/src/player.ts index 243161c..adadaaa 100644 --- a/src/player.ts +++ b/src/player.ts @@ -2,7 +2,7 @@ import { StateResponse, QueueItem, ActivityReasonCode } from './types.js' import { isBlocked, markBlocked } from './blocklist.js' import { getQueue, getCurrent, setCurrent, takeNextPlayable } from './queue.js' import { peekNextFallbackTrack, advanceFallback } from './fallback.js' -import { finishItem } from './finish.js' +import { finishItem, type ItemOutcome } from './finish.js' import { createStore } from './store.js' import { createLogger } from './logger.js' @@ -50,22 +50,37 @@ export function moveToNext(): QueueItem | null { return getCurrent() } -export function skipCurrent(): QueueItem | null { - const skipped = getCurrent() - if (skipped) { - log.log(`skipped "${skipped.title}"`) +// The one way the current track leaves the player. The outcome owns the log line, and finishItem turns it +// into the journal entry and the reward decision; whatever plays next is decided here alone +function leaveCurrent(outcome: ItemOutcome): QueueItem | null { + const leaving = getCurrent() + + if (leaving) { + if (outcome === 'skipped') { + log.log(`skipped "${leaving.title}"`) + } else if (typeof outcome === 'object') { + if (outcome.code === 'BLOCKED') log.log(`current track was blocked, skipping: "${leaving.title}"`) + else { + // a track that cannot be played is routine (the journal and the chat reply say why): an error entry would be noise + log.warn(`playback failed: "${leaving.title}" (error ${outcome.params?.errorCode ?? 'unknown'})`) + } + } + // 'played' is the routine way out: the line about what plays next tells the story } - finishItem(skipped, 'skipped') + + finishItem(leaving, outcome) return moveToNext() } +export function skipCurrent(): QueueItem | null { + return leaveCurrent('skipped') +} + export function skipIfCurrent(videoId: string): boolean { const current = getCurrent() if (current?.videoId !== videoId) return false - log.log(`current track was blocked, skipping: "${current.title}"`) - finishItem(current, { code: 'BLOCKED' }) - moveToNext() + leaveCurrent({ code: 'BLOCKED' }) return true } @@ -76,9 +91,9 @@ const PLAYBACK_FAILURE_REASONS: Record<number, ActivityReasonCode> = { 150: 'PLAYBACK_EMBED_DISALLOWED' } +// -1 is never in the table, so an unreported code falls through to the generic reason function playbackFailureReasonCode(errorCode?: number): ActivityReasonCode { - if (errorCode !== undefined) return PLAYBACK_FAILURE_REASONS[errorCode] ?? 'PLAYBACK_FAILED' - return 'PLAYBACK_FAILED' + return PLAYBACK_FAILURE_REASONS[errorCode ?? -1] ?? 'PLAYBACK_FAILED' } function isAboutCurrent(videoId?: string, playId?: number): boolean { @@ -94,8 +109,7 @@ export function endCurrent(videoId?: string, playId?: number): boolean { return false } - finishItem(getCurrent(), 'played') - moveToNext() + leaveCurrent('played') return true } @@ -107,14 +121,6 @@ export function reportPlaybackFailure(errorCode?: number, videoId?: string, play return false } - const failed = getCurrent() - if (failed) { - const reasonCode = playbackFailureReasonCode(errorCode) - // a track that cannot be played is routine (the journal and the chat reply say why): an error entry would be noise - log.warn(`playback failed: "${failed.title}" (error ${errorCode ?? 'unknown'})`) - finishItem(failed, { code: reasonCode, params: errorCode !== undefined ? { errorCode } : undefined }) - } - - moveToNext() + leaveCurrent({ code: playbackFailureReasonCode(errorCode), params: errorCode !== undefined ? { errorCode } : undefined }) return true } diff --git a/src/queue.ts b/src/queue.ts index 1671a10..ec554ba 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -1,8 +1,5 @@ import { getConfig } from './config.js' -import { getVideoById, searchSongs, selectBestSong } from './youtube/index.js' -import { parseYouTubeUrl } from './youtube/url.js' -import { logRejection, logAcceptance } from './activity.js' -import { QueueItem, Song, AppError, AddedSong } from './types.js' +import { QueueItem, Song, AppError } from './types.js' import { isBlocked } from './blocklist.js' import { createStore } from './store.js' import { finishItem } from './finish.js' @@ -26,13 +23,17 @@ const log = createLogger('QUEUE') // Everything below is derived from `queue` on demand, so there is no second copy that could drift apart from it const isQueued = (videoId: string): boolean => queue.some((item) => item.videoId === videoId) +// Whether the track already has a place in the playing state: it is the current one, or it is waiting in the queue +export function isDuplicate(videoId: string): boolean { + return isActiveNonFallback(videoId) || isQueued(videoId) +} + function getUserActiveCount(username: string): number { const key = username.toLowerCase() - // a blocked track never plays, so it no longer occupies the viewer's slot - const isTheirs = (item: QueueItem): boolean => !isBlocked(item.videoId) && item.requestedBy.toLowerCase() === key - const playing = currentSong !== null && !currentSong.isFallback && isTheirs(currentSong) + // a blocked track never plays and a playlist track is nobody's request: neither occupies the viewer's slot + const isTheirs = (item: QueueItem): boolean => !item.isFallback && !isBlocked(item.videoId) && item.requestedBy.toLowerCase() === key - return queue.filter(isTheirs).length + (playing ? 1 : 0) + return queue.filter(isTheirs).length + (currentSong !== null && isTheirs(currentSong) ? 1 : 0) } function isActiveNonFallback(videoId: string): boolean { @@ -124,7 +125,9 @@ export type AddOptions = { isFallback?: boolean } -function assertCanRequestSong(requestedBy: string, { bypassFilters = false }: AddOptions = {}): void { +// The refusals a new track can meet at the door, each in its own code, so every path that adds one answers +// the same way (the search filters out candidates by the same predicates before they are ever looked at) +export function assertCanRequestSong(requestedBy: string, { bypassFilters = false }: AddOptions = {}): void { const config = getConfig() // blocked tracks do not occupy places: they are closed when their turn comes @@ -141,13 +144,13 @@ function assertCanRequestSong(requestedBy: string, { bypassFilters = false }: Ad } } -function assertNotDuplicate(videoId: string): void { - if (isActiveNonFallback(videoId) || isQueued(videoId)) { +export function assertNotDuplicate(videoId: string): void { + if (isDuplicate(videoId)) { throw new AppError('DUPLICATE') } } -function assertNotBlocked(videoId: string): void { +export function assertNotBlocked(videoId: string): void { if (isBlocked(videoId)) { throw new AppError('BLOCKED') } @@ -236,50 +239,3 @@ export function clearQueue(): QueueItem[] { return cleared } - -// Adds the song the query names to the queue, or throws the AppError that says why not (a duplicate, -// a limit, a filter, a bad URL); the activity journal entry is written on the way out, in one place -export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: AddOptions = {}): Promise<AddedSong> { - let song: Song | null = null - - try { - assertCanRequestSong(requestedBy, { bypassFilters }) - - const { isYouTube, videoId } = parseYouTubeUrl(query) - log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) - - if (isYouTube && !videoId) { - throw new AppError('INVALID_YOUTUBE_URL') - } - - if (videoId) { - assertNotBlocked(videoId) - assertNotDuplicate(videoId) - song = await getVideoById(videoId, bypassFilters) - } else { - const songs = await searchSongs(query, bypassFilters) - song = selectBestSong( - songs, - (candidate) => !isBlocked(candidate.videoId) && !isQueued(candidate.videoId) && !isActiveNonFallback(candidate.videoId) - ) - } - - if (!song) throw new AppError('SONG_NOT_FOUND') - - const { item, started } = addSong(song, requestedBy, { bypassFilters, channelPointsRedemption }) - - log.log(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) - logAcceptance(requestedBy, query, song.title, song.videoId) - - return { song: item, started, position: started ? 0 : queue.length } - } catch (error) { - // an expected refusal (duplicate, limit, filter) is routine and logRejection() writes it down; anything - // else is a bug or an outage, and the handler that owns the request logs it once with its details - logRejection(requestedBy, query, error instanceof AppError ? error.code : 'SERVER_ERROR', { - title: song?.title ?? null, - videoId: song?.videoId ?? null, - reasonParams: error instanceof AppError ? error.params : undefined - }) - throw error - } -} diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..9cb195e --- /dev/null +++ b/src/request.ts @@ -0,0 +1,53 @@ +import { getVideoById, searchSongs, selectBestSong } from './youtube/index.js' +import { parseYouTubeUrl } from './youtube/url.js' +import { logRejection, logAcceptance } from './activity.js' +import { Song, AppError, AddedSong } from './types.js' +import { isBlocked } from './blocklist.js' +import { addSong, assertCanRequestSong, assertNotBlocked, assertNotDuplicate, getQueue, isDuplicate, type AddOptions } from './queue.js' +import { createLogger } from './logger.js' + +const log = createLogger('REQUEST') + +// Adds the song the query names to the queue, or throws the AppError that says why not (a duplicate, +// a limit, a filter, a bad URL); the activity journal entry is written on the way out, in one place +export async function requestSong(query: string, requestedBy: string, { bypassFilters = false, channelPointsRedemption }: AddOptions = {}): Promise<AddedSong> { + let song: Song | null = null + + try { + assertCanRequestSong(requestedBy, { bypassFilters }) + + const { isYouTube, videoId } = parseYouTubeUrl(query) + log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) + + if (isYouTube && !videoId) { + throw new AppError('INVALID_YOUTUBE_URL') + } + + if (videoId) { + assertNotBlocked(videoId) + assertNotDuplicate(videoId) + song = await getVideoById(videoId, bypassFilters) + } else { + const songs = await searchSongs(query, bypassFilters) + song = selectBestSong(songs, (candidate) => !isBlocked(candidate.videoId) && !isDuplicate(candidate.videoId)) + } + + if (!song) throw new AppError('SONG_NOT_FOUND') + + const { item, started } = addSong(song, requestedBy, { bypassFilters, channelPointsRedemption }) + + log.log(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) + logAcceptance(requestedBy, query, song.title, song.videoId) + + return { song: item, started, position: started ? 0 : getQueue().length } + } catch (error) { + // an expected refusal (duplicate, limit, filter) is routine and logRejection() writes it down; anything + // else is a bug or an outage, and the handler that owns the request logs it once with its details + logRejection(requestedBy, query, error instanceof AppError ? error.code : 'SERVER_ERROR', { + title: song?.title ?? null, + videoId: song?.videoId ?? null, + reasonParams: error instanceof AppError ? error.params : undefined + }) + throw error + } +} diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 8dfc2c3..1be71a1 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -4,7 +4,8 @@ import { ok } from '../http.js' import { wantsBypassFilters } from '../local-only.js' import { checkQuery, validateVideoIdParam } from '../validate.js' import { t } from '../i18n.js' -import { requestSong, removeByVideoId, clearQueue } from '../queue.js' +import { requestSong } from '../request.js' +import { removeByVideoId, clearQueue } from '../queue.js' import { getState } from '../player.js' import { getSettings } from '../settings.js' diff --git a/src/state-file.ts b/src/state-file.ts index 0b88f6d..713a56e 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -63,8 +63,8 @@ export function sanitizeQueueItem(raw: unknown): QueueItem | null { return { ...song, requestedBy, - // a queue saved before the flag existed marked its playlist tracks only by the requester name - ...(isObject(raw) && (raw.isFallback === true || requestedBy.toLowerCase() === 'playlist') ? { isFallback: true } : {}), + // the flag is read as written: a name alone never marks a track, for a viewer may go by it too + ...(isObject(raw) && raw.isFallback === true ? { isFallback: true } : {}), ...(playId !== undefined ? { playId } : {}), ...(redemption ? { channelPointsRedemption: redemption } : {}) } diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 4ea7056..1bb7749 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -12,7 +12,24 @@ const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) cpSync(locales, join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) -const { buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage, buildQueueMessage } = await import('../../src/chat-replies.js') +const { buildNowPlayingMessage, buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage, buildQueueMessage } = await import('../../src/chat-replies.js') + +test('buildNowPlayingMessage', async (t) => { + const track = (extra: Record<string, unknown> = {}) => + ({ videoId: 'aaaaaaaaaaa', title: 'T', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'bob', ...extra }) as never + const playing = (current: unknown) => ({ current, queue: [], isPaused: false }) + + await t.test('names the viewer who requested the track', () => { + assert.equal(buildNowPlayingMessage(playing(track()), 'en'), 'Now playing: T (requested by bob)') + }) + + await t.test('a playlist track goes by the localized name, whatever marker it carries', () => { + const playlistTrack = track({ requestedBy: 'playlist', isFallback: true }) + + assert.equal(buildNowPlayingMessage(playing(playlistTrack), 'en'), 'Now playing: T (requested by Playlist)') + assert.equal(buildNowPlayingMessage(playing(playlistTrack), 'ru'), 'Сейчас играет: T (заказал Плейлист)') + }) +}) test('buildQueueMessage', async (t) => { const track = (videoId: string, isBlocked = false) => ({ videoId, title: `Track ${videoId}`, requestedBy: 'viewer', isBlocked }) diff --git a/tests/backend/fallback.test.ts b/tests/backend/fallback.test.ts index 80b6594..58c7ff7 100644 --- a/tests/backend/fallback.test.ts +++ b/tests/backend/fallback.test.ts @@ -104,18 +104,18 @@ test('the tracks the playlist itself queues', async (t) => { const item = fallback.queueFallbackTrack('track00') assert.equal(item?.isFallback, true) - assert.equal(item?.requestedBy, 'Playlist') + assert.equal(item?.requestedBy, 'playlist') assert.equal(fallback.queueFallbackTrack('nosuchtrack00'), null) }) - await t.test('the name follows the locale', async () => { + await t.test('the marker is stable whatever the locale: the localized name is put in where the track is shown', () => { updateSettings({ locale: 'ru' }) setCurrent({ ...song('playing000001'), requestedBy: 'viewer' }) - assert.equal(fallback.queueFallbackTrack('track05')?.requestedBy, 'Плейлист') - assert.equal(fallback.advanceFallback()?.requestedBy, 'Плейлист') + assert.equal(fallback.queueFallbackTrack('track05')?.requestedBy, 'playlist') + assert.equal(fallback.advanceFallback()?.requestedBy, 'playlist') updateSettings({ locale: 'en' }) - assert.equal(fallback.peekNextFallbackTrack()?.requestedBy, 'Playlist') + assert.equal(fallback.peekNextFallbackTrack()?.requestedBy, 'playlist') }) }) diff --git a/tests/backend/finish.test.ts b/tests/backend/finish.test.ts index 891d4a2..8ab11c6 100644 --- a/tests/backend/finish.test.ts +++ b/tests/backend/finish.test.ts @@ -1,11 +1,15 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync } from 'node:fs' +import { cpSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' -// data/ and cache/ are read relative to the working directory: keep the test away from the real ones -process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) +// data/ and cache/ are read relative to the working directory: keep the test away from the real ones. +// The real locale files are copied along: the journal names the playlist through them +// (copied, not symlinked: creating a symlink needs extra privileges on Windows) +const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) +cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) +process.chdir(workDir) const { registerRedemptionHandler } = await import('../../src/finish.js') const { getActivity, clearActivity } = await import('../../src/activity.js') @@ -108,6 +112,18 @@ test('a Channel Points redemption is closed by the way its track leaves', async assert.deepEqual(getActivity(), []) }) + await t.test('a journal entry for a playlist track names the playlist, not the marker it carries', () => { + queue.setCurrent({ ...song(A), requestedBy: 'playlist', isFallback: true }) + blockTrack(A, 'Track a') + + player.skipIfCurrent(A) + + const [entry, ...others] = getActivity() + assert.equal(others.length, 0) + assert.equal(entry.status, 'rejected') + assert.equal(entry.requestedBy, 'Playlist') + }) + await t.test('playing a playlist track right away skips what is playing now the same way', () => { queue.setCurrent({ ...song(A), requestedBy: 'user', channelPointsRedemption: redemption('r1') }) fallback.hydrateFallback({ sourceTracks: [song(B2)], order: [B2], cursor: -1, playlistId: 'PLskiptest00001', lastRefreshedAt: null }) diff --git a/tests/backend/integrations/twitch/chat-commands.test.ts b/tests/backend/integrations/twitch/chat-commands.test.ts index 2fc3efb..0e10df8 100644 --- a/tests/backend/integrations/twitch/chat-commands.test.ts +++ b/tests/backend/integrations/twitch/chat-commands.test.ts @@ -13,7 +13,8 @@ const { getState, setPaused, getIsPaused } = await import('../../../../src/playe const { _test, _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') const { clearTwitchOAuthState } = await import('../../../../src/secrets.js') -import type { TwitchChatMessage, TwitchChat } from '../../../../src/integrations/twitch/types.js' +import type { TwitchChatMessage } from '../../../../src/integrations/twitch/types.js' +import type { TwitchChat } from '../../../../src/integrations/twitch/chat.js' function message(overrides: Partial<TwitchChatMessage> = {}): TwitchChatMessage { return { diff --git a/tests/backend/player.test.ts b/tests/backend/player.test.ts index 933d1d2..473db3a 100644 --- a/tests/backend/player.test.ts +++ b/tests/backend/player.test.ts @@ -40,6 +40,21 @@ const FAILING_CODES: [number | undefined, string][] = [ [undefined, 'PLAYBACK_FAILED'] ] +test('endCurrent() leaves no journal entry of its own: a play that ran its course is not news', async (t) => { + const A = 'aaaaaaaaaaa' + const B = 'bbbbbbbbbbb' + + await t.test('a plain track is closed without a record and the queue moves on once', () => { + queue.setCurrent({ ...song(A), requestedBy: 'viewer' }) + queue.addSong(song(B), 'viewer', { bypassFilters: true }) + + assert.equal(player.endCurrent(A), true) + + assert.equal(queue.getCurrent()?.videoId, B) + assert.equal(getActivity().length, 0) + }) +}) + test('reportPlaybackFailure() journals why the track could not be played', async (t) => { const A = 'aaaaaaaaaaa' diff --git a/tests/backend/queue.user-limit.test.ts b/tests/backend/queue.user-limit.test.ts index a5652be..fc79465 100644 --- a/tests/backend/queue.user-limit.test.ts +++ b/tests/backend/queue.user-limit.test.ts @@ -90,6 +90,13 @@ test('the track that is playing counts toward the per-user limit', async (t) => assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'Playlist', { bypassFilters: false })) }) + await t.test('a playlist track waiting in the queue is nobody\'s request either', () => { + queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) + queue.addSong(song('aaaaaaaaaaa'), 'playlist', { bypassFilters: false, isFallback: true }) + + assert.doesNotThrow(() => queue.assertCanAddSong(song('bbbbbbbbbbb'), 'playlist', { bypassFilters: false })) + }) + await t.test('a track the streamer blocked frees the viewer\'s slot: it never plays', () => { queue.setCurrent({ ...song('zzzzzzzzzzz'), requestedBy: 'someone' }) queue.addSong(song('aaaaaaaaaaa'), 'Bob', { bypassFilters: false }) diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index 4fee42e..119793f 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -195,10 +195,11 @@ test('sanitizeQueueItem()', async (t) => { assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: ' ' }), null) }) - await t.test('a queue saved before the fallback flag knows its playlist tracks by the name', () => { - assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'playlist' })?.isFallback, true) - assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'Playlist' })?.isFallback, true) - assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'bob' })?.isFallback, undefined) + await t.test('the fallback flag is restored as written, and a name alone never sets it', () => { + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'bob', isFallback: true })?.isFallback, true) + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'Playlist' })?.isFallback, undefined) + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'playlist', isFallback: 'yes' })?.isFallback, undefined) + assert.equal(sanitizeQueueItem({ videoId: A, title: 'T', requestedBy: 'bob', isFallback: 1 })?.isFallback, undefined) }) }) diff --git a/tests/backend/youtube/client.test.ts b/tests/backend/youtube/client.test.ts index b3a592d..7ff6427 100644 --- a/tests/backend/youtube/client.test.ts +++ b/tests/backend/youtube/client.test.ts @@ -4,7 +4,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { VideoItem } from '../../../src/youtube/types.js' -import { type Song, type FilterFailureReason } from '../../../src/types.js' +import type { Song, FilterFailureReason, Config } from '../../../src/types.js' // updateConfig() saves to data/config.json under the working directory: use a throwaway one, not the real one process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) diff --git a/tests/frontend/control/queue.test.js b/tests/frontend/control/queue.test.js index 65de69e..259bfa5 100644 --- a/tests/frontend/control/queue.test.js +++ b/tests/frontend/control/queue.test.js @@ -66,6 +66,21 @@ test('renderQueue', async (t) => { assert.equal(rows[0].querySelector('.status-pill'), null) assert.ok(rows[1].querySelector('.status-pill')) }) + + await t.test('a playlist track goes by the localized name, and follows the panel\'s language', async () => { + state.config = { maxQueueSize: 5 } + state.queue = [track('a', { isFallback: true, requestedBy: 'playlist' })] + + queue.renderQueue() + assert.equal(panel.$('#queueListWrapper .column-requested-by').textContent.trim(), 'Playlist') + + // the track still carries the same marker: only the language changed, and the row shows it + await initI18n('ru') + queue.renderQueue() + assert.equal(panel.$('#queueListWrapper .column-requested-by').textContent.trim(), 'Плейлист') + + await initI18n('en') + }) }) test('removeFromQueue', async (t) => { diff --git a/tests/frontend/overlay.test.js b/tests/frontend/overlay.test.js index 7c486e1..530bc7b 100644 --- a/tests/frontend/overlay.test.js +++ b/tests/frontend/overlay.test.js @@ -71,6 +71,13 @@ test('the badge', async (t) => { assert.equal(env.$('#currentRequester').textContent, '@bob') }) + await t.test('a playlist track goes by the localized name, not the marker it carries', async (t) => { + const { env } = await boot(t, { current: track('f', { isFallback: true, requestedBy: 'playlist' }) }) + + assert.equal(env.$('#currentTitle').textContent, 'Track f') + assert.equal(env.$('#currentRequester').textContent, 'Playlist') + }) + await t.test('follows the display settings: video only, opacity and position', async (t) => { const { env } = await boot(t, { current: track('a'), settings: { hideOverlayInfo: true, opacity: 40, position: 'top-left' } }) const badge = env.$('#badge') From 8731f88fa0a31dc2e90469516095b20f6d08ca90 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:02:36 +0700 Subject: [PATCH 14/22] Add strict TypeScript checking for tests TypeScript: - add a dedicated TypeScript configuration for tests - include test type-checking in the main typecheck script Tests: - add explicit types and required arguments to satisfy strict type checking --- package.json | 4 ++- tests/backend/chat-replies.test.ts | 25 +++++++++++-------- tests/backend/config.test.ts | 4 ++- .../integrations/twitch/redemptions.test.ts | 9 ++++--- tests/backend/youtube/client.test.ts | 14 +++++++++-- tsconfig.test.json | 12 +++++++++ 6 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 tsconfig.test.json diff --git a/package.json b/package.json index 16a58c7..a3cebfb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "test:frontend": "node --test \"tests/**/*.test.js\"", "test:smoke": "node scripts/smoke-frontend.mjs", "lint": "eslint .", - "typecheck": "tsc --noEmit", + "typecheck": "npm run typecheck:src && npm run typecheck:tests", + "typecheck:src": "tsc --noEmit", + "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", "build": "tsc", "build:sea": "node scripts/build-sea.mjs", "build:release": "node scripts/build-release.mjs" diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 1bb7749..4021bfb 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -13,11 +13,12 @@ cpSync(locales, join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) const { buildNowPlayingMessage, buildRedemptionAcceptedMessage, buildRedemptionRejectionMessage, buildQueueMessage } = await import('../../src/chat-replies.js') +import type { Blockable, PlayerState, QueueItem } from '../../src/types.js' test('buildNowPlayingMessage', async (t) => { - const track = (extra: Record<string, unknown> = {}) => - ({ videoId: 'aaaaaaaaaaa', title: 'T', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'bob', ...extra }) as never - const playing = (current: unknown) => ({ current, queue: [], isPaused: false }) + const track = (extra: Partial<QueueItem> = {}): QueueItem => + ({ videoId: 'aaaaaaaaaaa', title: 'T', channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'bob', ...extra }) + const playing = (current: PlayerState['current']): PlayerState => ({ current, queue: [], isPaused: false }) await t.test('names the viewer who requested the track', () => { assert.equal(buildNowPlayingMessage(playing(track()), 'en'), 'Now playing: T (requested by bob)') @@ -32,7 +33,9 @@ test('buildNowPlayingMessage', async (t) => { }) test('buildQueueMessage', async (t) => { - const track = (videoId: string, isBlocked = false) => ({ videoId, title: `Track ${videoId}`, requestedBy: 'viewer', isBlocked }) + const track = (videoId: string, isBlocked = false): Blockable<QueueItem> => ({ + videoId, title: `Track ${videoId}`, channelTitle: '', thumbnail: '', duration: 1, views: 1, url: 'u', requestedBy: 'viewer', isBlocked + }) await t.test('lists the first titles and counts everything', () => { const message = buildQueueMessage({ queue: [track('a'), track('b')] }, 'en') @@ -62,17 +65,17 @@ test('buildQueueMessage', async (t) => { test('buildRedemptionAcceptedMessage', async (t) => { await t.test('names the track and its place in the queue', () => { - const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'Never Gonna Give You Up' }, started: false, position: 3 }) + const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'Never Gonna Give You Up' }, started: false, position: 3 }, 'en') assert.equal(message, '@alice, “Never Gonna Give You Up” added to the queue [#3]') }) await t.test('a track that starts right away says it is playing now', () => { - const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'Song' }, started: true, position: 0 }) + const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'Song' }, started: true, position: 0 }, 'en') assert.equal(message, '@alice, “Song” added, playing now') }) await t.test('a long title is shortened', () => { - const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'A'.repeat(100) }, started: false, position: 1 }) + const message = buildRedemptionAcceptedMessage('alice', { song: { title: 'A'.repeat(100) }, started: false, position: 1 }, 'en') assert.ok(message.includes(`“${'A'.repeat(39)}…”`)) }) }) @@ -80,23 +83,23 @@ test('buildRedemptionAcceptedMessage', async (t) => { test('buildRedemptionRejectionMessage', async (t) => { // the reason is a plain { code, params } — a thrown AppError fits the same shape when one reaches this far await t.test('a request that was never queued gets only the reason', () => { - assert.equal(buildRedemptionRejectionMessage('alice', { code: 'DUPLICATE' }), '@alice, that track is already in the queue, points refunded') + assert.equal(buildRedemptionRejectionMessage('alice', { code: 'DUPLICATE' }, 'en'), '@alice, that track is already in the queue, points refunded') }) await t.test('a playback failure names the track and the reason', () => { - const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_VIDEO_UNAVAILABLE', params: { title: 'Song' } }) + const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_VIDEO_UNAVAILABLE', params: { title: 'Song' } }, 'en') assert.equal(message, '@alice, “Song” could not be played: the video became unavailable, points refunded') }) await t.test('every playback failure reason names the track', () => { for (const code of ['PLAYBACK_VIDEO_UNAVAILABLE', 'PLAYBACK_EMBED_DISALLOWED', 'PLAYBACK_FAILED'] as const) { - const message = buildRedemptionRejectionMessage('alice', { code, params: { title: 'Song' } }) + const message = buildRedemptionRejectionMessage('alice', { code, params: { title: 'Song' } }, 'en') assert.ok(message.includes('“Song”') && !message.includes('{{'), code) } }) await t.test('a long title in a playback failure is shortened', () => { - const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_FAILED', params: { title: 'A'.repeat(100) } }) + const message = buildRedemptionRejectionMessage('alice', { code: 'PLAYBACK_FAILED', params: { title: 'A'.repeat(100) } }, 'en') assert.ok(message.includes(`“${'A'.repeat(39)}…”`)) }) }) diff --git a/tests/backend/config.test.ts b/tests/backend/config.test.ts index 821b200..498e472 100644 --- a/tests/backend/config.test.ts +++ b/tests/backend/config.test.ts @@ -9,6 +9,8 @@ process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) const { getConfig, updateConfig, validateConfigUpdates } = await import('../../src/config.js') const { flushAllStores } = await import('../../src/persist.js') +import type { Config } from '../../src/types.js' +import type { DeepPartial } from '../../src/persist.js' const DEFAULTS = getConfig() @@ -139,7 +141,7 @@ test('updateConfig()', async (t) => { assert.equal(updateConfig({ minViews: 4242 }).changed, false, 'the same value') assert.equal(updateConfig({}).changed, false, 'an empty update') - assert.equal(updateConfig({ minViews: 4242, nope: 1 }).changed, false, 'a fully rejected update') + assert.equal(updateConfig({ minViews: 4242, nope: 1 } as DeepPartial<Config>).changed, false, 'a fully rejected update') await flushAllStores() assert.equal(existsSync(file), false, 'config.json was not rewritten') diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index 1087477..b5cee2a 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -17,12 +17,13 @@ const { updateTwitchConfig } = await import('../../../../src/integrations/twitch const queue = await import('../../../../src/queue.js') import type { TwitchChat } from '../../../../src/integrations/twitch/chat.js' import type { TwitchClient } from '../../../../src/integrations/twitch/client.js' +import type { FailureReason } from '../../../../src/types.js' const redemption = { id: 'red-1', rewardId: 'reward-1', userName: 'viewer' } // the reason a redemption is canceled travels as a plain { code, params }, not as a thrown AppError -const reason = { code: 'PLAYBACK_FAILED' } -const refunded = () => buildRedemptionRejectionMessage('viewer', reason) -const refundFailed = () => buildRedemptionRefundFailedMessage('viewer') +const reason: FailureReason = { code: 'PLAYBACK_FAILED' } +const refunded = () => buildRedemptionRejectionMessage('viewer', reason, 'en') +const refundFailed = () => buildRedemptionRefundFailedMessage('viewer', 'en') // the song requests below reach the YouTube layer, which needs a key to get past its own gate updateSecrets({ youtubeApiKey: 'test-key' }) @@ -228,7 +229,7 @@ test('handleChannelPointsRedemption()', async (t) => { assert.equal(errors.mock.callCount(), 1, 'the handler that owns the request logs the outage, nobody else') assert.deepEqual(calls, ['CANCELED']) - assert.deepEqual(sent, [buildRedemptionRejectionMessage('viewer', { code: 'SERVER_ERROR' })]) + assert.deepEqual(sent, [buildRedemptionRejectionMessage('viewer', { code: 'SERVER_ERROR' }, 'en')]) }) await t.test('a failure after the track is queued does not refund the points', async () => { diff --git a/tests/backend/youtube/client.test.ts b/tests/backend/youtube/client.test.ts index 7ff6427..3a08f5f 100644 --- a/tests/backend/youtube/client.test.ts +++ b/tests/backend/youtube/client.test.ts @@ -13,7 +13,12 @@ const { isAvailableInRegion, checkFilters, isMusicVideo } = await import('../../ const { updateConfig, getConfig } = await import('../../../src/config.js') // the reason the filters would answer with, or null when the track passes them all -const reasonOf = (song: Song, video: VideoItem): FilterFailureReason | null => checkFilters(song, video, getConfig())?.code ?? null +// (checkFilters answers with an AppError whose code is typed as the broad AppErrorCode union; +// only the filter reasons ever reach this helper, hence the narrowing cast) +const reasonOf = (song: Song, video: VideoItem): FilterFailureReason | null => { + const failure = checkFilters(song, video, getConfig()) + return failure ? (failure.code as FilterFailureReason) : null +} function baseSong(overrides: Partial<Song> = {}): Song { return { @@ -121,7 +126,12 @@ test('checkFilters', async (t) => { }) await t.test('guardOnly: the guard rules alone say whether a track can be played at all', () => { - const config = { contentMode: 'music', minViews: 100_000, minDurationSeconds: 60, maxDurationSeconds: 480, allowShorts: false, regionCode: 'RU' } + // the fields the filters never read are filled in with the repo defaults: only the six below matter + const config: Config = { + contentMode: 'music', minViews: 100_000, minDurationSeconds: 60, maxDurationSeconds: 480, allowShorts: false, regionCode: 'RU', + maxQueueSize: 20, maxRequestsPerUser: 4, allowLiveStreams: false, + fallbackPlaylist: { playlistId: null, enabled: true, shuffle: false, repeat: false } + } const refusedByViewerRules = { ...baseSong({ duration: 120, views: 5 }) } const video = baseVideo({ snippet: { categoryId: '20' } }) diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1e2afe5 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} From 489130d72ed90f8f2f9f172706cc2dc23ee42e06 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:24:47 +0700 Subject: [PATCH 15/22] Centralize networking and harden application lifecycle Networking: - centralize network and fetch handling - improve Twitch socket recovery and outage reporting - make application shutdown safer Configuration: - reduce config-read overhead with read-only live views - enforce strict secret updates YouTube: - refine API response handling and data mapping Validation and localization: - share input validation limits - add localized placeholders --- public/index.html | 2 +- public/js/control/player.js | 8 +- public/js/control/views/blocklist.js | 4 +- public/js/control/views/fallback.js | 6 +- public/js/control/views/playlists.js | 2 +- public/js/control/views/queue.js | 6 +- public/js/control/views/search.js | 6 +- public/js/overlay.js | 6 +- public/js/shared.js | 10 + public/locales/en.json | 10 +- public/locales/ru.json | 10 +- src/activity.ts | 4 +- src/blocklist.ts | 4 +- src/config-helper.ts | 34 ++- src/fallback.ts | 12 +- src/host-check.ts | 5 +- src/i18n.ts | 17 +- src/integrations/twitch/chat.ts | 14 +- src/integrations/twitch/client.ts | 29 +- src/integrations/twitch/config.ts | 21 +- src/integrations/twitch/eventsub.ts | 54 +++- src/integrations/twitch/index.ts | 281 ++++++++++-------- src/integrations/twitch/oauth.ts | 31 +- src/integrations/twitch/rewards.ts | 15 +- src/integrations/twitch/socket.ts | 26 +- src/local-only.ts | 38 +-- src/logger.ts | 28 +- src/network.ts | 62 ++++ src/persist.ts | 4 +- src/player.ts | 10 +- src/queue.ts | 12 +- src/request.ts | 4 +- src/routes/playlists.ts | 6 +- src/routes/queue.ts | 4 +- src/routes/settings.ts | 4 +- src/routes/system.ts | 9 +- src/routes/twitch.ts | 4 +- src/secrets.ts | 23 +- src/server.ts | 43 ++- src/shutdown.ts | 30 +- src/state-file.ts | 28 +- src/types.ts | 1 + src/validate.ts | 15 +- src/web-fetch.ts | 40 +++ src/youtube/client.ts | 235 ++------------- src/youtube/filters.ts | 146 +++++++++ src/youtube/index.ts | 94 +++--- src/youtube/mapper.ts | 43 +++ src/youtube/types.ts | 52 ++-- tests/backend/config.test.ts | 10 +- .../integrations/twitch/close-quietly.test.ts | 27 +- .../integrations/twitch/config.test.ts | 34 ++- .../integrations/twitch/eventsub.test.ts | 51 +++- tests/backend/secrets.test.ts | 8 +- tests/backend/shutdown.test.ts | 2 + tests/backend/state-file.test.ts | 3 +- tests/backend/twitch-rewards.test.ts | 10 +- tests/backend/youtube/client.test.ts | 2 +- tests/backend/youtube/request.test.ts | 5 +- 59 files changed, 1032 insertions(+), 672 deletions(-) create mode 100644 src/network.ts create mode 100644 src/web-fetch.ts create mode 100644 src/youtube/filters.ts create mode 100644 src/youtube/mapper.ts diff --git a/public/index.html b/public/index.html index 79be948..a7dbad4 100644 --- a/public/index.html +++ b/public/index.html @@ -659,7 +659,7 @@ <h2 data-i18n="settings.twitch.editReward">Edit reward</h2> </label> <label class="field"> <span class="field-label" data-i18n="settings.twitch.createRewardPrompt">Description (optional)</span> - <input class="settings-input input" type="text" id="twitchRewardPrompt" name="prompt" maxlength="140" autocomplete="off" /> + <input class="settings-input input" type="text" id="twitchRewardPrompt" name="prompt" maxlength="200" autocomplete="off" /> </label> <label class="field" for="twitchRewardBackgroundColor"> <span class="field-label" data-i18n="settings.twitch.createRewardBackgroundColor">Background color (hex, optional)</span> diff --git a/public/js/control/player.js b/public/js/control/player.js index 7697304..56a8fe9 100644 --- a/public/js/control/player.js +++ b/public/js/control/player.js @@ -1,4 +1,4 @@ -import { formatDuration, formatViews, getErrorMessage, show, setText } from '../shared.js' +import { displayChannel, displayTitle, formatDuration, formatViews, getErrorMessage, show, setText } from '../shared.js' import { PLAY_ICON, PAUSE_ICON } from '../icons.js' import { state } from './state.js' import { dom } from './dom.js' @@ -21,8 +21,8 @@ export function renderCurrent() { show(dom.nowPlaying) show(dom.noPlaying, false) - setText(dom.currentTitle, current.title) - setText(dom.currentChannel, current.channelTitle) + setText(dom.currentTitle, displayTitle(current.title)) + setText(dom.currentChannel, displayChannel(current.channelTitle)) setText(dom.currentDuration, formatDuration(current.duration)) setText(dom.currentViews, t('player.views', { count: formatViews(current.views, getCurrentLocale()) })) setText(dom.currentRequester, requesterName(current)) @@ -33,7 +33,7 @@ export function renderNext() { show(dom.nextPlaying, !!next) if (!next) return - setText(dom.nextTitle, next.title) + setText(dom.nextTitle, displayTitle(next.title)) setText(dom.nextDuration, formatDuration(next.duration)) } diff --git a/public/js/control/views/blocklist.js b/public/js/control/views/blocklist.js index ca24da2..0b31be1 100644 --- a/public/js/control/views/blocklist.js +++ b/public/js/control/views/blocklist.js @@ -1,4 +1,4 @@ -import { youtubeThumbnail } from '../../shared.js' +import { displayTitle, youtubeThumbnail } from '../../shared.js' import { dom } from '../dom.js' import { formatDateTime } from '../ui.js' import { createListView, row, unblockTrackItem } from './primitives.js' @@ -9,7 +9,7 @@ export const blocklistView = createListView(dom.blocklistListWrapper, { row({ index: formatDateTime(entry.blockedAt, '\n'), thumbnail: youtubeThumbnail(entry.videoId), - title: entry.title, + title: displayTitle(entry.title), actions: unblockTrackItem(entry.videoId) }) }) diff --git a/public/js/control/views/fallback.js b/public/js/control/views/fallback.js index a18ea5b..614fbcb 100644 --- a/public/js/control/views/fallback.js +++ b/public/js/control/views/fallback.js @@ -1,4 +1,4 @@ -import { formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, formatDuration } from '../../shared.js' import { PLUS_ICON, PLAY_ICON } from '../../icons.js' import { t } from '../../i18n.js' import { dom } from '../dom.js' @@ -12,8 +12,8 @@ export const fallbackView = createListView(dom.fallbackListWrapper, { className: track.isActive ? 'row-active' : '', attributes: dataAttributes({ videoId: track.videoId }).trim(), thumbnail: track.thumbnail, - title: track.title, - subtitle: track.channelTitle, + title: displayTitle(track.title), + subtitle: displayChannel(track.channelTitle), meta: formatDuration(track.duration), extra: track.isActive ? statusPill(t('fallback.playing'), 'info') : track.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : '', actions: track.isBlocked diff --git a/public/js/control/views/playlists.js b/public/js/control/views/playlists.js index 2cf3e56..0834a40 100644 --- a/public/js/control/views/playlists.js +++ b/public/js/control/views/playlists.js @@ -10,7 +10,7 @@ export const playlistsView = createListView(dom.playlistsListWrapper, { row({ className: playlist.isActive ? 'row-active' : '', thumbnail: playlist.thumbnail, - title: playlist.title, + title: playlist.title || t('playlists.untitled'), subtitle: t('playlists.tracks', { count: playlist.itemCount }), actions: rowMenu([ { diff --git a/public/js/control/views/queue.js b/public/js/control/views/queue.js index 23eab63..c992735 100644 --- a/public/js/control/views/queue.js +++ b/public/js/control/views/queue.js @@ -1,4 +1,4 @@ -import { escapeHtml, formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, escapeHtml, formatDuration } from '../../shared.js' import { DELETE_ICON } from '../../icons.js' import { t, requesterName } from '../../i18n.js' import { dom } from '../dom.js' @@ -11,8 +11,8 @@ export const queueView = createListView(dom.queueListWrapper, { row({ index: index + 1, thumbnail: item.thumbnail, - title: item.title, - subtitle: item.channelTitle, + title: displayTitle(item.title), + subtitle: displayChannel(item.channelTitle), extra: ` ${item.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : ''} <span class="column-requested-by text-sm text-green truncate"> diff --git a/public/js/control/views/search.js b/public/js/control/views/search.js index 21f2b44..68e36af 100644 --- a/public/js/control/views/search.js +++ b/public/js/control/views/search.js @@ -1,4 +1,4 @@ -import { escapeHtml, formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, escapeHtml, formatDuration } from '../../shared.js' import { PLUS_ICON } from '../../icons.js' import { t } from '../../i18n.js' import { dom } from '../dom.js' @@ -9,8 +9,8 @@ export const searchView = createListView(dom.searchListWrapper, { renderRow: (song) => row({ thumbnail: song.thumbnail, - title: song.title, - subtitle: song.channelTitle, + title: displayTitle(song.title), + subtitle: displayChannel(song.channelTitle), extra: song.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : '', meta: formatDuration(song.duration), actions: ` diff --git a/public/js/overlay.js b/public/js/overlay.js index 0d22e61..1a4e171 100644 --- a/public/js/overlay.js +++ b/public/js/overlay.js @@ -1,4 +1,4 @@ -import { $, formatDuration, createLogger, getErrorMessage, show, setClass, setText } from './shared.js' +import { $, createLogger, displayTitle, formatDuration, getErrorMessage, show, setClass, setText } from './shared.js' import { initI18n, t, getCurrentLocale, requesterName } from './i18n.js' import { loadYouTubeApi } from './youtube-api.js' import { claimPlayback } from './playback-lock.js' @@ -75,11 +75,11 @@ function renderCurrent() { dom.badge.dataset.position = settings.position dom.currentThumbnail.src = currentState.current.thumbnail - setText(dom.currentTitle, currentState.current.title) + setText(dom.currentTitle, displayTitle(currentState.current.title)) setText(dom.currentRequester, requesterName(currentState.current)) if (currentState.nextTrack) { - setText(dom.nextTitle, currentState.nextTrack.title) + setText(dom.nextTitle, displayTitle(currentState.nextTrack.title)) setText(dom.nextElapsedTime, formatDuration(currentState.nextTrack.duration)) show(dom.nextPlaying) diff --git a/public/js/shared.js b/public/js/shared.js index f648223..f110043 100644 --- a/public/js/shared.js +++ b/public/js/shared.js @@ -1,3 +1,5 @@ +import { t } from './i18n.js' + export const $ = (id) => document.getElementById(id) export function escapeHtml(value) { @@ -142,3 +144,11 @@ export function setChecked(element, checked) { export function setText(element, text) { element.textContent = text } + +export function displayTitle(title) { + return title || t('common.unknownTitle') +} + +export function displayChannel(channelTitle) { + return channelTitle || t('common.unknownChannel') +} diff --git a/public/locales/en.json b/public/locales/en.json index 383d9a7..ac59336 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -45,7 +45,9 @@ "close": "Close", "on": "On", "off": "Off", - "more": "More actions" + "more": "More actions", + "unknownTitle": "Unknown title", + "unknownChannel": "Unknown channel" }, "nav": { "dashboard": "Dashboard", @@ -120,7 +122,8 @@ "activate": "Play playlist", "pause": "Pause playlist", "delete": "Delete playlist", - "deleteConfirm": "Remove this playlist?" + "deleteConfirm": "Remove this playlist?", + "untitled": "Untitled playlist" }, "settings": { "title": "Settings", @@ -277,7 +280,8 @@ "twitchRefreshError": "Failed to refresh Twitch connection", "twitchApiError": "Twitch API error: {{message}}", "twitchNetworkError": "No connection to Twitch", - "twitchUnknownError": "Unknown Twitch error" + "twitchUnknownError": "Unknown Twitch error", + "twitchConnectionLost": "Connection to Twitch was lost for {{minutes}} min: check the redemptions in the Twitch dashboard" } }, "toast": { diff --git a/public/locales/ru.json b/public/locales/ru.json index 5898cbb..31b9203 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -45,7 +45,9 @@ "close": "Закрыть", "on": "Вкл", "off": "Выкл", - "more": "Ещё действия" + "more": "Ещё действия", + "unknownTitle": "Неизвестное название", + "unknownChannel": "Неизвестный канал" }, "nav": { "dashboard": "Панель", @@ -120,7 +122,8 @@ "activate": "Воспроизвести плейлист", "pause": "Пауза плейлиста", "delete": "Удалить плейлист", - "deleteConfirm": "Удалить этот плейлист?" + "deleteConfirm": "Удалить этот плейлист?", + "untitled": "Плейлист без названия" }, "settings": { "title": "Настройки", @@ -277,7 +280,8 @@ "twitchRefreshError": "Не удалось обновить подключение Twitch", "twitchApiError": "Ошибка Twitch API: {{message}}", "twitchNetworkError": "Нет связи с Twitch", - "twitchUnknownError": "Неизвестная ошибка Twitch" + "twitchUnknownError": "Неизвестная ошибка Twitch", + "twitchConnectionLost": "Связь с Twitch пропадала на {{minutes}} мин: проверьте награды в панели Twitch" } }, "toast": { diff --git a/src/activity.ts b/src/activity.ts index aa47bf0..f3d9c68 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -46,7 +46,7 @@ export function clearActivity(): void { entries.update((list) => { list.length = 0 }) - log.log('cleared') + log.info('cleared') } type ReasonParams = Record<string, string | number> @@ -57,7 +57,7 @@ export function logRejection( reasonCode: ActivityReasonCode, options: { title?: string | null; videoId?: string | null; reasonParams?: ReasonParams } = {} ): void { - log.log(`[REJECT] ${requestedBy} → ${reasonCode}`) + log.info(`[REJECT] ${requestedBy} → ${reasonCode}`) logActivity({ requestedBy, query, diff --git a/src/blocklist.ts b/src/blocklist.ts index 8e733a8..474aa9b 100644 --- a/src/blocklist.ts +++ b/src/blocklist.ts @@ -1,3 +1,4 @@ +import { INPUT_LIMITS } from './validate.js' import { dataPath, createListStore } from './persist.js' import { createLogger } from './logger.js' import { isValidVideoId } from './youtube/url.js' @@ -11,7 +12,6 @@ export type BlockedTrack = { // Entries older than the limit are dropped, which makes an old blocked track requestable again, so the limit is high and the drop is logged const BLOCKLIST_LIMIT = 1000 -const TITLE_MAX_LENGTH = 200 const log = createLogger('BLOCKLIST') @@ -53,7 +53,7 @@ export function markBlocked<T extends { videoId: string }>(tracks: T[]): Blockab export function blockTrack(videoId: string, title: string): BlockedTrack { blocked.update((list) => { if (!list.some((track) => track.videoId === videoId)) { - list.unshift({ videoId, title: title.trim().slice(0, TITLE_MAX_LENGTH) || videoId, blockedAt: Date.now() }) + list.unshift({ videoId, title: title.trim().slice(0, INPUT_LIMITS.blocklistTitle) || videoId, blockedAt: Date.now() }) if (list.length > BLOCKLIST_LIMIT) { const dropped = list.splice(BLOCKLIST_LIMIT) diff --git a/src/config-helper.ts b/src/config-helper.ts index 3d9427e..b1b42cf 100644 --- a/src/config-helper.ts +++ b/src/config-helper.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util' -import { createFileStore, deepMerge, type DeepPartial } from './persist.js' +import { createFileStore, deepMerge, isPlain, type DeepPartial } from './persist.js' import { createLogger, describeError } from './logger.js' import { emit, type Topic } from './state-events.js' @@ -30,11 +30,10 @@ export const rules = { oneOf: (values: readonly unknown[]): FieldRule => field({ validate: (v) => values.includes(v) }) } -const isPlainObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) const isRule = (entry: FieldRule | Schema): entry is FieldRule => FIELD in entry function validateSchema(schema: Schema, raw: unknown, path: string, rejected: string[]): Record<string, unknown> | undefined { - if (!isPlainObject(raw)) { + if (!isPlain(raw)) { rejected.push(path || 'body') return undefined } @@ -67,6 +66,13 @@ export function validateUpdates(schema: Schema, updates: unknown): { clean: Reco return { clean, rejected } } +// A deep readonly view of a config: getConfig() hands out the live object instead of a clone per read, +// so every read stops paying structuredClone (every chat message, every /api/state); the type keeps hands off it. +// A mutable config stays assignable to it, so tests and callers that build their own object keep compiling. +export type DeepReadonly<T> = { + readonly [K in keyof T]: T[K] extends (...args: never[]) => unknown ? T[K] : T[K] extends object ? DeepReadonly<T[K]> : T[K] +} + export function createConfigModule<T extends object>(options: { filePath: string | (() => string) defaults: T @@ -100,8 +106,7 @@ export function createConfigModule<T extends object>(options: { return { clean, rejected } } - function updateConfig(updates: DeepPartial<T>) { - const { clean, rejected } = validateConfigUpdates(updates) + function commit(clean: DeepPartial<T>): { config: DeepReadonly<T>; changed: boolean } { const previous = current() const next = deepMerge(previous, clean) const changed = !isDeepStrictEqual(next, previous) @@ -110,7 +115,21 @@ export function createConfigModule<T extends object>(options: { save() announce(previous, next) } - return { config: structuredClone(next), rejected, changed } + return { config: next as DeepReadonly<T>, changed } + } + + function updateConfig(updates: DeepPartial<T>) { + const { clean, rejected } = validateConfigUpdates(updates) + const { config, changed } = commit(clean) + return { config, rejected, changed } + } + + // All-or-nothing: for updates that come from one form and must not land half-applied (the secrets: + // a valid API key must not be saved while a bad field of the same request is refused). Validates once. + function updateConfigStrict(updates: DeepPartial<T>): { rejected: string[]; changed: boolean } { + const { clean, rejected } = validateConfigUpdates(updates) + if (rejected.length) return { rejected, changed: false } + return { rejected, ...commit(clean) } } function restoreConfig(snapshot: T): void { @@ -142,9 +161,10 @@ export function createConfigModule<T extends object>(options: { } return { - getConfig: (): T => structuredClone(current()), + getConfig: (): DeepReadonly<T> => current(), validateConfigUpdates, updateConfig, + updateConfigStrict, restoreConfig, onConfigChange, saveNow: async (onError?: (error: unknown) => void) => { diff --git a/src/fallback.ts b/src/fallback.ts index cc55063..6e13d8f 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -240,12 +240,12 @@ export async function refreshFallback(): Promise<FallbackStateResponse> { } const { songs: newTracks, truncated, itemCount } = await fetchPlaylistSongs(playlistId) - if (truncated) log.log(`Playlist was truncated due to page limit (${newTracks.length} tracks loaded)`) + if (truncated) log.info(`Playlist was truncated due to page limit (${newTracks.length} tracks loaded)`) const config = getConfig() if (config.fallbackPlaylist.playlistId !== playlistId) { - log.log(`Refresh for "${playlistId}" discarded - playlist changed during load`) + log.info(`Refresh for "${playlistId}" discarded - playlist changed during load`) return getFallbackState() } @@ -260,7 +260,7 @@ export async function refreshFallback(): Promise<FallbackStateResponse> { rotation.itemCount = itemCount }) - log.log(`Loaded ${rotation.tracksById.size} tracks from playlist ${playlistId}`) + log.info(`Loaded ${rotation.tracksById.size} tracks from playlist ${playlistId}`) return getFallbackState() } @@ -282,7 +282,7 @@ function toggleFallbackFlag(flag: FallbackFlag): FallbackStateResponse { updateConfig({ fallbackPlaylist: patch }) - log.log(`${flag}: ${next}`) + log.info(`${flag}: ${next}`) return getFallbackState() } @@ -294,7 +294,7 @@ export function clearFallback(): FallbackStateResponse { rotationStore.update(() => resetRotation()) updateConfig({ fallbackPlaylist: { playlistId: null } }) - log.log('cleared') + log.info('cleared') return getFallbackState() } @@ -338,7 +338,7 @@ export function playFallbackTrackNow(videoId: string): QueueItem | null { }) const item = toFallbackQueueItem(song) setCurrent(item) - log.log(`Play now: "${song.title}"`) + log.info(`Play now: "${song.title}"`) return item } diff --git a/src/host-check.ts b/src/host-check.ts index 390d4fa..13f6579 100644 --- a/src/host-check.ts +++ b/src/host-check.ts @@ -1,7 +1,6 @@ -import os from 'node:os' import type { NextFunction, Request, Response } from 'express' import { fail } from './http.js' -import { ownAddresses } from './local-only.js' +import { computerHostname, ownAddresses } from './network.js' function originHost(origin: string | undefined): string { try { @@ -23,7 +22,7 @@ function isAllowedHost(hostHeader: string | undefined): boolean { if (!host) return false if (host === 'localhost' || host.endsWith('.localhost')) return true - const computer = os.hostname().toLowerCase() + const computer = computerHostname() if (host === computer || host === `${computer}.local` || host === `${computer}.lan`) return true return ownAddresses().has(host) diff --git a/src/i18n.ts b/src/i18n.ts index d333efd..79e47f4 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -14,22 +14,31 @@ type Dict = { [key: string]: Dict | string } const cache = new Map<string, Dict>() const reported = new Set<string>() -function loadLocale(locale: string): Dict { +const LOAD_FAILURE_TTL_MS = 30_000 +const failedAt = new Map<string, number>() + +function loadLocale(locale: string): Dict | null { const cached = cache.get(locale) if (cached) return cached + // unreadable right now: retry at most once per interval, not on every reply + const failed = failedAt.get(locale) + if (failed !== undefined && Date.now() - failed < LOAD_FAILURE_TTL_MS) return null + try { const raw = fs.readFileSync(path.join(localesDir(), `${locale}.json`), 'utf-8') const dict = JSON.parse(raw) as Dict cache.set(locale, dict) + failedAt.delete(locale) return dict } catch (error) { - // not cached, so a later call retries; reported once, otherwise every chat reply and API error would repeat it + // remembered, so a later call retries after the interval instead of on every chat reply and API error + failedAt.set(locale, Date.now()) if (!reported.has(locale)) { reported.add(locale) log.error(`Failed to load locale "${locale}": ${describeError(error)}`) } - return {} + return null } } @@ -54,7 +63,7 @@ export function t(locale: string, key: string, params: Record<string, string | n const value = template(locale, key) if (value === undefined) return key // the key itself is the last resort; hasKey() tells it apart from a real text - return Object.entries(params).reduce((acc, [param, replacement]) => acc.replaceAll(`{{${param}}}`, () => String(replacement)), value) + return value.replace(/\{\{(\w+)\}\}/g, (match, param: string) => (param in params ? String(params[param]) : match)) } // Whether a translation exists (in the locale, or in the default one): the way to tell a missing key from diff --git a/src/integrations/twitch/chat.ts b/src/integrations/twitch/chat.ts index 8a1027e..52090b7 100644 --- a/src/integrations/twitch/chat.ts +++ b/src/integrations/twitch/chat.ts @@ -4,6 +4,7 @@ import { ReconnectingSocket } from './socket.js' import type { TwitchChatMessage } from './types.js' import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' +import { INPUT_LIMITS } from '../../validate.js' const log = createLogger('TWITCH CHAT') @@ -11,12 +12,11 @@ const IRC_WS_URL = 'wss://irc-ws.chat.twitch.tv:443' const PING_INTERVAL_MS = 2 * 60 * 1000 // Twitch answers our PING at once: no data at all for this long means the connection is dead const WATCHDOG_MS = PING_INTERVAL_MS + 60_000 -const MAX_MESSAGE_LENGTH = 500 +const MAX_MESSAGE_LENGTH = INPUT_LIMITS.chatMessage export type TwitchChatOptions = { oauth: TwitchOAuth channelLogin: string - botLogin: string onMessage?: (message: TwitchChatMessage) => Promise<void> | void } @@ -110,7 +110,7 @@ export class TwitchChat extends ReconnectingSocket { protected onOpen(socket: WebSocket): void { socket.send(`PASS oauth:${this.accessToken}`) - socket.send(`NICK ${this.config.botLogin}`) + socket.send(`NICK ${this.config.channelLogin}`) socket.send('CAP REQ :twitch.tv/commands twitch.tv/tags') socket.send(`JOIN #${this.config.channelLogin}`) this.accessToken = '' @@ -146,8 +146,8 @@ export class TwitchChat extends ReconnectingSocket { // below, since a bad channel name would otherwise be reported as connected return case 'JOIN': - if (prefix?.split('!')[0] === this.config.botLogin && params[0] === `#${this.config.channelLogin}`) { - log.log(`Joined channel #${this.config.channelLogin}`) + if (prefix?.split('!')[0] === this.config.channelLogin && params[0] === `#${this.config.channelLogin}`) { + log.info(`Joined channel #${this.config.channelLogin}`) this.markReady() this.startPingLoop() } @@ -172,7 +172,9 @@ export class TwitchChat extends ReconnectingSocket { const userLogin = prefix?.split('!')[0] ?? '' const displayName = tags['display-name'] || userLogin const isBroadcaster = hasBadge(tags.badges, 'broadcaster') - const isModerator = tags.mod === '1' || hasBadge(tags.badges, 'moderator') || isBroadcaster + // the account is the channel's own: "mod" is 1 exactly when the user moderates the channel, so the + // moderator badge would only repeat what the tag already says + const isModerator = tags.mod === '1' || isBroadcaster const message: TwitchChatMessage = { channel: this.config.channelLogin, diff --git a/src/integrations/twitch/client.ts b/src/integrations/twitch/client.ts index e7ef3f1..860b86e 100644 --- a/src/integrations/twitch/client.ts +++ b/src/integrations/twitch/client.ts @@ -13,12 +13,16 @@ import { TwitchOAuth } from './oauth.js' import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' import { markTransient, normalizeTwitchError } from './error.js' +import { fetchWithTimeout } from '../../web-fetch.js' const log = createLogger('TWITCH CLIENT') const REQUEST_TIMEOUT_MS = 15_000 const REWARDS_URL = 'https://api.twitch.tv/helix/channel_points/custom_rewards' +// StreamQueue's own reward always takes the viewer's input: the flag lives once, not in each method that writes it +const REWARD_REQUIRES_INPUT = true + export const CHANNEL_POINTS_REDEMPTION = 'channel.channel_points_custom_reward_redemption.add' export class TwitchClient { @@ -43,6 +47,9 @@ export class TwitchClient { // another app, a missing scope), so it is reported as it is, without a pointless refresh if (response.status !== 401) return this.handleResponse<T>(response) + // the refused body is never read on this path: drain it, so the connection is not held until it times out + await response.body?.cancel().catch(() => {}) + let refreshedToken: Awaited<ReturnType<TwitchOAuth['refreshAccessToken']>> try { refreshedToken = await this.oauth.refreshAccessToken() @@ -59,16 +66,14 @@ export class TwitchClient { } private async request(url: string, options: RequestInit, accessToken: string): Promise<Response> { + // headers arrive as a plain object, a Headers or an array of pairs: a spread would silently lose the + // non-plain forms, so they merge instead + const headers = new Headers(options.headers) + headers.set('Authorization', `Bearer ${accessToken}`) + headers.set('Client-Id', this.oauth.getConfig().clientId) + try { - return await fetch(url, { - ...options, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - headers: { - ...options.headers, - Authorization: `Bearer ${accessToken}`, - 'Client-Id': this.oauth.getConfig().clientId - } - }) + return await fetchWithTimeout(url, { ...options, headers }, REQUEST_TIMEOUT_MS) } catch (error) { throw normalizeTwitchError(error) } @@ -129,7 +134,7 @@ export class TwitchClient { profileImageUrl: userData.profile_image_url } - log.log(`Retrieved user info for: ${this.userInfo.displayName}`) + log.info(`Retrieved user info for: ${this.userInfo.displayName}`) return this.userInfo } @@ -159,13 +164,13 @@ export class TwitchClient { async createCustomReward(data: TwitchCreateCustomReward): Promise<TwitchCustomReward> { const params = new URLSearchParams({ broadcaster_id: this.broadcasterId }) - return this.sendForOne(`${REWARDS_URL}?${params}`, 'POST', { ...data, is_user_input_required: true }, 'Twitch API returned no created reward') + return this.sendForOne(`${REWARDS_URL}?${params}`, 'POST', { ...data, is_user_input_required: REWARD_REQUIRES_INPUT }, 'Twitch API returned no created reward') } async updateCustomReward(rewardId: string, data: TwitchUpdateCustomReward): Promise<TwitchCustomReward> { const params = new URLSearchParams({ broadcaster_id: this.broadcasterId, id: rewardId }) - return this.sendForOne(`${REWARDS_URL}?${params}`, 'PATCH', { ...data, is_user_input_required: true }, 'Twitch API returned no updated reward') + return this.sendForOne(`${REWARDS_URL}?${params}`, 'PATCH', { ...data, is_user_input_required: REWARD_REQUIRES_INPUT }, 'Twitch API returned no updated reward') } // Subscribes a live EventSub WebSocket session to Channel Points redemptions of the connected channel diff --git a/src/integrations/twitch/config.ts b/src/integrations/twitch/config.ts index b856671..ce4591e 100644 --- a/src/integrations/twitch/config.ts +++ b/src/integrations/twitch/config.ts @@ -53,23 +53,20 @@ export const { }, refine: (clean, current, reject) => { - if (!clean.chatCommands) return + const updated = CHAT_COMMAND_KEYS.filter((key) => typeof clean.chatCommands?.[key]?.command === 'string') + if (updated.length === 0) return - const stored = current.chatCommands - const merged = deepMerge(stored, clean.chatCommands) + // One pass over the changed commands: each is checked against the merged view (stored + this update), + // so the final state must be collision-free - a mutual exchange lands in one step this way + const merged = deepMerge(current.chatCommands, clean.chatCommands ?? {}) const overlaps = (a: string, b: string) => a === b || a.startsWith(`${b} `) || b.startsWith(`${a} `) const collides = (key: (typeof CHAT_COMMAND_KEYS)[number]) => CHAT_COMMAND_KEYS.some((other) => other !== key && overlaps(merged[other].command, merged[key].command)) - for (let pass = 0; pass < CHAT_COMMAND_KEYS.length; pass++) { - const conflicting = CHAT_COMMAND_KEYS.filter((key) => collides(key) && merged[key].command !== stored[key].command) - if (conflicting.length === 0) return - - for (const key of conflicting) { - merged[key] = { ...merged[key], command: stored[key].command } - delete clean.chatCommands[key]?.command - reject(`chatCommands.${key}.command`) - } + for (const key of updated) { + if (!collides(key)) continue + delete clean.chatCommands?.[key]?.command + reject(`chatCommands.${key}.command`) } } }) diff --git a/src/integrations/twitch/eventsub.ts b/src/integrations/twitch/eventsub.ts index 835b584..078a5e3 100644 --- a/src/integrations/twitch/eventsub.ts +++ b/src/integrations/twitch/eventsub.ts @@ -1,7 +1,9 @@ +import type WebSocket from 'ws' import { CHANNEL_POINTS_REDEMPTION, type TwitchClient } from './client.js' import { ReconnectingSocket, closeQuietly } from './socket.js' import type { EventSubMessage, TwitchChannelPointsRedemption } from './types.js' import { createLogger, describeError } from '../../logger.js' +import { logActivity } from '../../activity.js' import { normalizeTwitchError } from './error.js' const log = createLogger('TWITCH EVENTSUB') @@ -20,6 +22,44 @@ export class TwitchEventSub extends ReconnectingSocket { private recentMessageIds: string[] = [] private static readonly MAX_RECENT_MESSAGE_IDS = 100 + // A dropped session loses whatever Twitch sent while it was down (a laptop asleep, a changed network), and + // the streamer can only learn it from the journal. A real outage leaves a line there; a blip (and Twitch's + // own seamless reconnect request) stays below the threshold and out of the journal + private hadReadySession = false + private connectionLostAt: number | null = null + protected journalAfterMs = 60_000 + + protected markReady(): void { + super.markReady() + this.hadReadySession = true + } + + protected onOpen(_socket: WebSocket): void { + const lostAt = this.connectionLostAt + this.connectionLostAt = null + if (lostAt === null) return + + const downMs = Date.now() - lostAt + if (downMs < this.journalAfterMs) return + + logActivity({ + requestedBy: 'Twitch', + query: 'Twitch', + title: null, + videoId: null, + status: 'failed', + reasonCode: 'TWITCH_CONNECTION_LOST', + reasonParams: { minutes: Math.round(downMs / 60_000) } + }) + } + + protected onClosed(): void { + // an intentional stop loses nothing (stopped is set before the teardown): no alarm afterwards + if (this.stopped) return + if (this.hadReadySession) this.connectionLostAt = Date.now() + this.hadReadySession = false + } + constructor(config: TwitchEventSubOptions) { super() this.config = config @@ -68,10 +108,10 @@ export class TwitchEventSub extends ReconnectingSocket { return } - log.log(`Session connected: ${sessionId}`) + log.info(`Session connected: ${sessionId}`) if (this.isReconnect) { - log.log('Reconnected session: subscription carries over, not re-subscribing') + log.info('Reconnected session: subscription carries over, not re-subscribing') this.isReconnect = false this.markReady() this.watchKeepalive(message) @@ -80,13 +120,13 @@ export class TwitchEventSub extends ReconnectingSocket { try { await this.config.client.subscribeToRedemptions(sessionId) - log.log('Subscribed to Channel Points redemptions') + log.info('Subscribed to Channel Points redemptions') this.markReady() this.watchKeepalive(message) } catch (error) { const normalized = normalizeTwitchError(error) + log.error(`EventSub subscription failed: ${describeError(normalized)}`) this.abort(normalized) - throw normalized } } @@ -112,7 +152,7 @@ export class TwitchEventSub extends ReconnectingSocket { const messageId = message.metadata.message_id if (this.recentMessageIds.includes(messageId)) { - log.log(`Ignoring redelivered notification: ${messageId}`) + log.info(`Ignoring redelivered notification: ${messageId}`) return } @@ -144,12 +184,12 @@ export class TwitchEventSub extends ReconnectingSocket { const oldSocket = this.socket this.isReconnect = true - log.log('Twitch requested reconnect') + log.info('Twitch requested reconnect') try { await this.open(reconnectUrl) await this.waitReady() - log.log('Reconnected') + log.info('Reconnected') } catch (error) { log.error(`Reconnect failed: ${describeError(error)}`) this.isReconnect = false diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 7a6efbe..9f7b4f4 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -2,6 +2,9 @@ import { TwitchOAuth } from './oauth.js' import { TwitchClient } from './client.js' import { TwitchEventSub } from './eventsub.js' import { TwitchChat } from './chat.js' +import { ReconnectingSocket } from './socket.js' +import { retryableTransport } from '../../web-fetch.js' +import { setTimeout as sleep } from 'node:timers/promises' import type { TwitchAuthConfig, TwitchChannelPointsRedemption, @@ -42,8 +45,67 @@ let retryDelayMs = REDEMPTION_RETRY_DELAY_MS let oauth: TwitchOAuth | null = null let client: TwitchClient | null = null -let eventSub: TwitchEventSub | null = null -let chat: TwitchChat | null = null +// The two long-lived sockets of a connected account, written once and driven the same way everywhere: +// start, stop and the test reset walk the list instead of naming each socket three times over +interface ManagedSocket<T extends ReconnectingSocket> { + name: string + logger: ReturnType<typeof createLogger> + create: () => T | null + instance: T | null + starting: Promise<void> | null +} + +const eventSubSocket: ManagedSocket<TwitchEventSub> = { + name: 'Twitch EventSub', + logger: log, + instance: null, + starting: null, + create: () => { + const twitchClient = client + if (!twitchClient?.getCachedUserInfo()) { + log.info('Twitch account not connected, EventSub will not start') + return null + } + + if (!oauth?.getConfig().clientId) { + log.info('Twitch client ID not configured, EventSub will not start') + return null + } + + return new TwitchEventSub({ + client: twitchClient, + onChannelPointsRedemption: (event) => handleChannelPointsRedemption(event, twitchClient) + }) + } +} + +const chatSocket: ManagedSocket<TwitchChat> = { + name: 'Twitch chat', + logger: chatLog, + instance: null, + starting: null, + create: () => { + const userInfo = client?.getCachedUserInfo() + if (!oauth || !userInfo) { + chatLog.info('Twitch account not connected, chat will not start') + return null + } + + // Started whenever the account is connected, with or without enabled commands: the replies to redemptions + // (accepted, refunded) go through the chat too, and which commands react is decided per message + return new TwitchChat({ + oauth, + channelLogin: userInfo.login, + onMessage: handleChatMessage + }) + } +} + +const managedSockets: ManagedSocket<ReconnectingSocket>[] = [eventSubSocket, chatSocket] + +function currentChat(): TwitchChat | null { + return chatSocket.instance +} let deviceAuthorizationPromise: Promise<unknown> | null = null let deviceAuthorizationGeneration = 0 @@ -68,7 +130,7 @@ function handleChannelPointsPlaybackOutcome(tracked: TrackedRedemption, outcome: } if (!getTwitchConfig().autoFulfillRedemptions) { - log.log(`Redemption ${tracked.id} finished playback, left unfulfilled (auto-fulfill disabled)`) + log.info(`Redemption ${tracked.id} finished playback, left unfulfilled (auto-fulfill disabled)`) return } @@ -77,12 +139,12 @@ function handleChannelPointsPlaybackOutcome(tracked: TrackedRedemption, outcome: export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = {}): void { if (oauth) { - log.log('Twitch integration already initialized') + log.info('Twitch integration already initialized') return } if (!config.clientId) { - log.log('Twitch client ID not configured, integration will not be functional') + log.info('Twitch client ID not configured, integration will not be functional') return } @@ -100,20 +162,19 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = if (secrets.twitch.tokenData) { oauth.setTokenData(secrets.twitch.tokenData) - log.log('Restored Twitch token data from storage') + log.info('Restored Twitch token data from storage') } if (secrets.twitch.userInfo) { twitchClient.setCachedUserInfo(secrets.twitch.userInfo) - log.log('Restored Twitch user info from storage') + log.info('Restored Twitch user info from storage') } registerRedemptionHandler((tracked, outcome) => handleChannelPointsPlaybackOutcome(tracked, outcome, twitchClient)) - log.log('Twitch integration initialized') + log.info('Twitch integration initialized') - void startEventSub() - void startChat() + void startSockets() } async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemption, twitchClient: TwitchClient): Promise<void> { @@ -126,13 +187,13 @@ async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemptio // a repeated delivery of the same redemption must not take the points twice if (handledRedemptions.has(event.id)) { - log.log(`Ignoring redemption ${event.id}: it was already handled`) + log.info(`Ignoring redemption ${event.id}: it was already handled`) return } handledRedemptions.add(event.id) if (handledRedemptions.size > HANDLED_REDEMPTIONS_LIMIT) handledRedemptions.delete(handledRedemptions.values().next().value as string) - log.log(`Channel Points redemption: ${event.reward.title} by ${event.user_name}`) + log.info(`Channel Points redemption: ${event.reward.title} by ${event.user_name}`) const tracked = trackRedemption(event) @@ -172,50 +233,16 @@ async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemptio return } - log.log(`Redemption ${event.id} added to queue`) + log.info(`Redemption ${event.id} added to queue`) const locale = getSettings().locale await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) } -let eventSubStart: Promise<void> | null = null - -// One run at a time: device authorization finishing together with "refresh connection" must not connect the same socket twice -function startEventSub(): Promise<void> { - eventSubStart ??= connectEventSub().finally(() => { - eventSubStart = null - }) - return eventSubStart -} - -async function connectEventSub(): Promise<void> { - if (!oauth || !client) return - - if (!eventSub) { - if (!client.getCachedUserInfo()) { - log.log('Twitch account not connected, EventSub will not start') - return - } - - if (!oauth.getConfig().clientId) { - log.log('Twitch client ID not configured, EventSub will not start') - return - } - - const twitchClient = client - eventSub = new TwitchEventSub({ - client: twitchClient, - onChannelPointsRedemption: (event) => handleChannelPointsRedemption(event, twitchClient) - }) - } - - if (!eventSub.isConnected()) await connectOrRetry(eventSub, 'Twitch EventSub', log) -} - // Without a network at startup the integration must not stay dead: the same instance keeps retrying by itself -async function connectOrRetry(socket: TwitchEventSub | TwitchChat, name: string, logger: ReturnType<typeof createLogger>): Promise<void> { +async function connectOrRetry(socket: ReconnectingSocket, name: string, logger: ReturnType<typeof createLogger>): Promise<void> { try { await socket.connect() - logger.log(`${name} connected`) + logger.info(`${name} connected`) } catch (error) { const normalized = normalizeTwitchError(error) logger.error(`Failed to start ${name}, will keep retrying: ${describeError(normalized)}`) @@ -248,33 +275,42 @@ const CHAT_CONNECT_POLL_MS = 250 let chatStartWaitMs = CHAT_START_WAIT_MS let chatReconnectWaitMs = CHAT_RECONNECT_WAIT_MS +async function connectSocket(entry: ManagedSocket<ReconnectingSocket>): Promise<void> { + if (!oauth || !client) return + + // a socket that says it cannot exist yet (no account, no client id) logs its own reason and stays null. + // The instance is captured: a test mock that replaces it mid-start must not redirect this connection + const socket = (entry.instance ??= entry.create()) + if (!socket) return + + if (!socket.isConnected()) await connectOrRetry(socket, entry.name, entry.logger) +} + +// One start per socket at a time: a second caller joins the start already running instead of racing it +function startSocket(entry: ManagedSocket<ReconnectingSocket>): Promise<void> { + entry.starting ??= connectSocket(entry).finally(() => { + entry.starting = null + }) + return entry.starting +} + +/** Starts the sockets of the connected account: each one's setup begins at once, as before (two voids became one call). */ +async function startSockets(): Promise<void> { + await Promise.all(managedSockets.map((entry) => startSocket(entry))) +} + async function waitForChat(limitMs: number): Promise<boolean> { const deadline = Date.now() + limitMs - while (!chat?.isConnected()) { + while (true) { + const chat = currentChat() if (!chat) return false + if (chat.isConnected()) return true + const remaining = deadline - Date.now() if (remaining <= 0) return false - await wait(Math.min(CHAT_CONNECT_POLL_MS, remaining)) + await sleep(Math.min(CHAT_CONNECT_POLL_MS, remaining)) } - - return true -} - -async function replyInChat(message: string): Promise<void> { - if (!chat) return - - if (!chat.isConnected()) { - const connected = await waitForChat(chatStart ? chatStartWaitMs : chatReconnectWaitMs) - if (!connected) { - chatLog.warn(`Chat not connected, skipping reply: ${message}`) - return - } - } - - await chat.sendMessage(message).catch((error) => { - chatLog.error(`Failed to send chat reply: ${describeError(error)}`) - }) } // One global (not per-user) cooldown per kind of command: two moderators can't double up on skip/pause/resume, @@ -282,7 +318,7 @@ async function replyInChat(message: string): Promise<void> { async function runCommand(message: TwitchChatMessage, cooldown: keyof typeof lastRun, cooldownSeconds: number, action: () => string): Promise<void> { const now = Date.now() if (now - lastRun[cooldown] < cooldownSeconds * 1000) { - chatLog.log(`Ignored a command from ${message.displayName}: cooldown active`) + chatLog.info(`Ignored a command from ${message.displayName}: cooldown active`) return } lastRun[cooldown] = now @@ -323,54 +359,43 @@ const COMMANDS: Record<(typeof CHAT_COMMAND_KEYS)[number], { cooldown: keyof typ } async function handleChatMessage(message: TwitchChatMessage): Promise<void> { + // every command starts with "!" (the config schema enforces the prefix): any other channel talk + // is over before it could cost a config read or a command comparison + if (!message.text.startsWith('!')) return + const commands = getTwitchConfig().chatCommands for (const key of CHAT_COMMAND_KEYS) { const { enabled, command, permission } = commands[key] if (!enabled || !matchesCommand(message.text, command) || !hasPermission(message, permission)) continue - chatLog.log(`Command "${command}" from ${message.displayName}`) + chatLog.info(`Command "${command}" from ${message.displayName}`) const { cooldown, run } = COMMANDS[key] await runCommand(message, cooldown, commands[cooldown], run) return } } -let chatStart: Promise<void> | null = null - -function startChat(): Promise<void> { - chatStart ??= connectChat().finally(() => { - chatStart = null - }) - return chatStart -} - -async function connectChat(): Promise<void> { - if (!oauth || !client) return +async function replyInChat(message: string): Promise<void> { + const chat = currentChat() + if (!chat) return - if (!chat) { - const userInfo = client.getCachedUserInfo() - if (!userInfo) { - chatLog.log('Twitch account not connected, chat will not start') + if (!chat.isConnected()) { + const connected = await waitForChat(chatSocket.starting ? chatStartWaitMs : chatReconnectWaitMs) + if (!connected) { + chatLog.warn(`Chat not connected, skipping reply: ${message}`) return } - - // Started whenever the account is connected, with or without enabled commands: the replies to redemptions - // (accepted, refunded) go through the chat too, and which commands react is decided per message - chat = new TwitchChat({ - oauth, - channelLogin: userInfo.login, - botLogin: userInfo.login, - onMessage: handleChatMessage - }) } - if (!chat.isConnected()) await connectOrRetry(chat, 'Twitch chat', chatLog) + await chat.sendMessage(message).catch((error) => { + chatLog.error(`Failed to send chat reply: ${describeError(error)}`) + }) } // What a login that waits for the code finds out is not used any more once it is cancelled (it only logs that it was) function cancelDeviceAuthorization(): void { - if (deviceAuthorizationPromise) log.log('Device authorization cancelled') + if (deviceAuthorizationPromise) log.info('Device authorization cancelled') deviceAuthorizationGeneration++ deviceAuthorizationPromise = null @@ -423,7 +448,7 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon // a refusal (4xx) or a refused token gives the same answer every time: repeating it only delays the rollback if (attempt === USER_INFO_RETRY_ATTEMPTS || !isRetryable(error)) break log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) - await wait(retryDelayMs * 2 ** (attempt - 1)) + await sleep(retryDelayMs * 2 ** (attempt - 1)) } } @@ -449,16 +474,15 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon connectedAt: Date.now() }) - await startEventSub() - await startChat() + await startSockets() - log.log(`Twitch account connected: ${fetchedUserInfo.displayName}`) + log.info(`Twitch account connected: ${fetchedUserInfo.displayName}`) return fetchedUserInfo }) // nobody awaits this promise: a refusal, an expired code or a cancel is logged here instead of becoming an unhandled rejection .catch((error) => { if (generation !== deviceAuthorizationGeneration) { - log.log('Twitch authorization was cancelled') + log.info('Twitch authorization was cancelled') return } @@ -483,10 +507,6 @@ export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeRespon return device } -function wait(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - // A repeat helps only when the failure can pass by itself: a lost connection or a timeout (they arrive as // plain errors where nothing normalized them on the way), a 429, a 5xx, or a login that could not be // refreshed right now (flagged `transient`). A refusal (4xx), a missing login or a token Twitch no longer @@ -495,8 +515,9 @@ export function isRetryable(error: unknown): boolean { if (!(error instanceof AppError)) return isNetworkError(error) if (error.params?.transient === 1) return true + // the same transport rule the fetch skeleton retries by: a 429, a 5xx const status = error.params?.status - return typeof status === 'number' && (status === 429 || status >= 500) + return typeof status === 'number' && retryableTransport({ status }) } // Twitch answers 404 when there is no UNFULFILLED redemption with that id: the streamer already closed it in the rewards @@ -519,7 +540,7 @@ async function setRedemptionStatus( const reason = describeError(error) if (isNoLongerOpen(error)) { - log.log( + log.info( `Redemption ${redemption.id} is not open any more (closed in the Twitch rewards queue, or its reward was deleted): ${status} not needed` ) return 'no-longer-open' @@ -534,7 +555,7 @@ async function setRedemptionStatus( log.error( `Failed to set redemption ${redemption.id} to ${status} (attempt ${attempt}/${REDEMPTION_RETRY_ATTEMPTS}): ${reason}. Retrying in ${delay}ms` ) - await wait(delay) + await sleep(delay) } } @@ -545,7 +566,7 @@ async function fulfillRedemption(redemption: TrackedRedemption, twitchClient: Tw const result = await setRedemptionStatus(redemption, twitchClient, 'FULFILLED') if (result === 'done') { - log.log(`Channel Points redemption fulfilled: ${redemption.id}`) + log.info(`Channel Points redemption fulfilled: ${redemption.id}`) return } @@ -562,7 +583,7 @@ async function cancelRedemption(redemption: TrackedRedemption, twitchClient: Twi // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise if (result === 'done') { - log.log(`Channel Points redemption canceled (points refunded): ${redemption.id}`) + log.info(`Channel Points redemption canceled (points refunded): ${redemption.id}`) const locale = getSettings().locale await replyInChat(buildRedemptionRejectionMessage(redemption.userName, reason, locale)) return @@ -584,28 +605,26 @@ export type TwitchHealth = { } export function getTwitchHealth(): TwitchHealth { + // named fields by name: the typed answer of the panel - the list would only hide what is written return { auth: oauth?.needsReauthorization() ? 'reauthorize' : 'ok', - eventSub: eventSub?.isConnected() ?? false, - chat: chat?.isConnected() ?? false + eventSub: eventSubSocket.instance?.isConnected() ?? false, + chat: chatSocket.instance?.isConnected() ?? false } } /** Disconnects the account. Returns how many queued redemptions were left UNFULFILLED on Twitch for the streamer to close by hand. */ export async function disconnect(): Promise<number> { if (!oauth || !client) { - log.log('Twitch integration not initialized, nothing to disconnect') + log.info('Twitch integration not initialized, nothing to disconnect') return 0 } - if (eventSub) { - await eventSub.disconnect() - eventSub = null - } - - if (chat) { - await chat.disconnect() - chat = null + for (const entry of managedSockets) { + if (entry.instance) { + await entry.instance.disconnect() + entry.instance = null + } } // a login that is still waiting for the code must not bring the account back after the disconnect @@ -621,7 +640,7 @@ export async function disconnect(): Promise<number> { client.clearUserInfo() clearTwitchOAuthState() - log.log('Twitch account disconnected') + log.info('Twitch account disconnected') return detached } @@ -633,10 +652,9 @@ export async function refreshConnection(): Promise<TwitchUserInfo> { updateTwitchOAuthState({ userInfo }) - await startEventSub() - await startChat() + await startSockets() - log.log('Twitch connection refreshed') + log.info('Twitch connection refreshed') return userInfo } catch (error) { log.error(`Failed to refresh connection: ${describeError(error)}`) @@ -663,7 +681,7 @@ export const _test = { lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 }, setChat: (mock: TwitchChat | null) => { - chat = mock + chatSocket.instance = mock }, setChatWaitLimits: (ms: { start: number; reconnect: number }) => { chatStartWaitMs = ms.start @@ -680,13 +698,14 @@ export const _test = { // Export for testing purposes only /** @internal exported for tests */ export async function _resetIntegration(): Promise<void> { - if (eventSub) await eventSub.disconnect() - if (chat) await chat.disconnect() + for (const entry of managedSockets) { + if (entry.instance) await entry.instance.disconnect() + entry.instance = null + entry.starting = null + } oauth = null client = null - eventSub = null - chat = null deviceAuthorizationPromise = null deviceAuthorizationGeneration = 0 handledRedemptions.clear() diff --git a/src/integrations/twitch/oauth.ts b/src/integrations/twitch/oauth.ts index 88ce491..49dfa23 100644 --- a/src/integrations/twitch/oauth.ts +++ b/src/integrations/twitch/oauth.ts @@ -1,4 +1,6 @@ import { createLogger, describeError } from '../../logger.js' +import { fetchWithTimeout } from '../../web-fetch.js' +import { setTimeout as sleep } from 'node:timers/promises' import { emit } from '../../state-events.js' import { AppError } from '../../types.js' import { normalizeTwitchError } from './error.js' @@ -21,12 +23,15 @@ const TOKEN_URL = 'https://id.twitch.tv/oauth2/token' const REVOKE_URL = 'https://id.twitch.tv/oauth2/revoke' function postForm(url: string, params: URLSearchParams): Promise<Response> { - return fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: params.toString(), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) - }) + return fetchWithTimeout( + url, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }, + REQUEST_TIMEOUT_MS + ) } async function oauthError(response: Response, fallback: string): Promise<AppError> { @@ -75,7 +80,7 @@ export class TwitchOAuth { if (!response.ok) throw await oauthError(response, 'Device authorization failed') const data = (await response.json()) as TwitchDeviceCodeResponse - log.log('Device code requested') + log.info('Device code requested') return data } catch (error) { // the error is reported where it ends (the /connect answer carries it to the panel): @@ -94,7 +99,7 @@ export class TwitchOAuth { const currentGeneration = this.tokenGeneration while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, pollInterval)) + await sleep(pollInterval) if (!isWanted()) throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') const params = new URLSearchParams({ @@ -123,7 +128,7 @@ export class TwitchOAuth { const tokenData = this.store(data, currentGeneration) - log.log('Device authorization successful') + log.info('Device authorization successful') return tokenData } @@ -177,7 +182,7 @@ export class TwitchOAuth { scope: [...tokenData.scope] } - log.log('Token data set from storage') + log.info('Token data set from storage') } getTokenData(): TwitchTokenData | null { @@ -194,7 +199,7 @@ export class TwitchOAuth { this.refreshPromise = null this.tokenGeneration++ this.setAuthFailed(false) - log.log('Token data cleared') + log.info('Token data cleared') } // Tells Twitch to forget the tokens (best effort: the account is disconnected locally whatever the answer) @@ -282,13 +287,13 @@ export class TwitchOAuth { } const tokenData = this.store(data, expectedGeneration) - log.log('Token refresh successful') + log.info('Token refresh successful') return tokenData } catch (error) { if (error instanceof AppError) { // a refresh dropped because the account was disconnected meanwhile is an orderly exit, not a failure if (this.isCurrent(expectedGeneration)) log.error(describeError(error)) - else log.log('Token refresh dropped: the account was disconnected meanwhile') + else log.info('Token refresh dropped: the account was disconnected meanwhile') throw error } // Network errors become TWITCH_REFRESH_ERROR diff --git a/src/integrations/twitch/rewards.ts b/src/integrations/twitch/rewards.ts index 6320da7..d1ac638 100644 --- a/src/integrations/twitch/rewards.ts +++ b/src/integrations/twitch/rewards.ts @@ -1,4 +1,6 @@ import type { TwitchCreateCustomReward, TwitchCustomReward, TwitchUpdateCustomReward } from './types.js' +import { isPlain } from '../../persist.js' +import { INPUT_LIMITS } from '../../validate.js' // What the panel sends and what it gets back is the same object, so a form can be filled with a reward and sent as it is // (public/js/control/form.js: the name of an input is the path of its value in this object). @@ -17,8 +19,8 @@ export type RewardDto = { } } -const TITLE_MAX_LENGTH = 45 -const PROMPT_MAX_LENGTH = 140 +const TITLE_MAX_LENGTH = INPUT_LIMITS.rewardTitle +const PROMPT_MAX_LENGTH = INPUT_LIMITS.rewardPrompt // the name of a limit in the panel, the switch that Twitch has for it, and the number that goes with the switch const LIMITS = [ @@ -48,7 +50,6 @@ export function toRewardDto(reward: TwitchCustomReward): RewardDto { } } -const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) const isPositiveInteger = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 1 /** @@ -60,7 +61,7 @@ export function parseRewardInput( body: unknown, { requireTitleAndCost }: { requireTitleAndCost: boolean } ): { payload: TwitchCreateCustomReward | TwitchUpdateCustomReward; rejected: string[] } { - const input = isObject(body) ? body : {} + const input = isPlain(body) ? body : {} const payload: Record<string, string | number | boolean> = {} const rejected: string[] = [] const reject = (name: string) => rejected.push(name) @@ -97,14 +98,14 @@ export function parseRewardInput( else reject('isEnabled') } - if (input.limits !== undefined && !isObject(input.limits)) reject('limits') - const limits = isObject(input.limits) ? input.limits : {} + if (input.limits !== undefined && !isPlain(input.limits)) reject('limits') + const limits = isPlain(input.limits) ? input.limits : {} for (const { name, flag, value } of LIMITS) { const limit = limits[name] if (limit === undefined) continue - if (!isObject(limit) || typeof limit.enabled !== 'boolean') { + if (!isPlain(limit) || typeof limit.enabled !== 'boolean') { reject(`limits.${name}.enabled`) continue } diff --git a/src/integrations/twitch/socket.ts b/src/integrations/twitch/socket.ts index dab5732..3f83e4b 100644 --- a/src/integrations/twitch/socket.ts +++ b/src/integrations/twitch/socket.ts @@ -19,12 +19,28 @@ export function closeQuietly(socket: WebSocket | null | undefined): void { } } +/** + * For a connection already known to be dead (a watchdog kill, a network change): the close handshake will not + * complete on it, and ws would hold the socket and its timers for up to 30s - terminate() cuts through. + */ +export function terminateQuietly(socket: WebSocket | null | undefined): void { + if (!socket) return + + socket.removeAllListeners() + socket.on('error', () => {}) + + try { + socket.terminate() + } catch { + // already closed + } +} + export abstract class ReconnectingSocket { protected socket: WebSocket | null = null protected ready = false protected stopped = false - // maxAttempts is unlimited on purpose: after a sleep or a network outage the connection must come back by itself, however long it took - protected timing = { initialDelayMs: 5_000, maxDelayMs: 60_000, maxAttempts: Infinity, readyTimeoutMs: 10_000, stableAfterMs: 30_000 } + protected timing = { initialDelayMs: 5_000, maxDelayMs: 60_000, readyTimeoutMs: 10_000, stableAfterMs: 30_000 } protected abstract readonly log: ReturnType<typeof createLogger> protected abstract readonly url: string @@ -82,7 +98,7 @@ export abstract class ReconnectingSocket { if (!socket) return closeQuietly(socket) - this.log.log('Disconnected') + this.log.info('Disconnected') } // does not undo a disconnect(): the caller of a failed first connect() must not bring back a socket that was closed on purpose @@ -126,7 +142,8 @@ export abstract class ReconnectingSocket { this.socket = null this.teardown() this.reportStatus() - closeQuietly(socket) + // dead by the time it gets here: terminate, not a close whose handshake cannot complete + terminateQuietly(socket) } private markStable(): void { @@ -251,6 +268,7 @@ export abstract class ReconnectingSocket { return } + // no attempt cap: after a sleep or a network outage the connection must come back by itself, however long it took const delay = Math.min(this.timing.initialDelayMs * 2 ** this.attempts, this.timing.maxDelayMs) this.attempts++ diff --git a/src/local-only.ts b/src/local-only.ts index 62e9309..e19d9d9 100644 --- a/src/local-only.ts +++ b/src/local-only.ts @@ -1,37 +1,29 @@ import type { NextFunction, Request, Response } from 'express' -import os from 'node:os' import { fail } from './http.js' +import { isLocalAddress } from './network.js' -function isLoopbackAddress(address: string | undefined): boolean { - if (!address) return false - return address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.') +declare module 'express-serve-static-core' { + interface Request { + /** Stamped once per request by determineLocality when the middleware runs: whether the request comes from this machine */ + isLocal?: boolean + } } -const withoutMappedPrefix = (address: string): string => address.replace(/^::ffff:/i, '').toLowerCase() - -const ADDRESSES_TTL_MS = 5000 -let cachedAddresses: { at: number; set: Set<string> } | null = null - -export function ownAddresses(): Set<string> { - if (cachedAddresses && Date.now() - cachedAddresses.at < ADDRESSES_TTL_MS) return cachedAddresses.set - - const addresses = Object.values(os.networkInterfaces()) - .flatMap((interfaces) => interfaces ?? []) - .map((iface) => withoutMappedPrefix(iface.address.split('%')[0])) - - cachedAddresses = { at: Date.now(), set: new Set(addresses) } - return cachedAddresses.set +/** Stamps req.isLocal once, so every route that asks reads a memory read instead of re-asking the socket address. */ +export function determineLocality(req: Request, _res: Response, next: NextFunction): void { + req.isLocal = isLocalAddress(req.socket.remoteAddress) + next() } -export function isLocalAddress(address: string | undefined): boolean { - if (!address) return false - return isLoopbackAddress(address) || ownAddresses().has(withoutMappedPrefix(address.split('%')[0])) +// A test assembly that leaves the middleware out still gets the right answer: the stamp wins when it is there +export function isLocal(req: Request): boolean { + return req.isLocal ?? isLocalAddress(req.socket.remoteAddress) } // Only lets through requests that come from the machine the app runs on. For actions that must not be reachable // by other devices on the LAN, even though the API is otherwise open to them (shutdown, API key) export function localOnly(req: Request, res: Response, next: NextFunction): void { - if (isLocalAddress(req.socket.remoteAddress)) { + if (isLocal(req)) { next() return } @@ -43,5 +35,5 @@ export function localOnly(req: Request, res: Response, next: NextFunction): void // whichever way it asks, only this machine's own requests may actually skip them export function wantsBypassFilters(req: Request): boolean { const asked = (req.body as { admin?: unknown } | undefined)?.admin === true || req.query.admin === '1' - return asked && isLocalAddress(req.socket.remoteAddress) + return asked && isLocal(req) } diff --git a/src/logger.ts b/src/logger.ts index 0886546..3fbbc13 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -9,11 +9,11 @@ const WEIGHT: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 const MAX_FILE_BYTES = 1024 * 1024 const KEPT_ROTATED_FILES = 3 -// LOG_LEVEL=debug / info / warn / error (default info) -function minWeight(): number { +// LOG_LEVEL=debug / info / warn / error (default info); read once - it is a startup setting, not a per-line lookup +const MIN_WEIGHT = (() => { const raw = process.env.LOG_LEVEL?.trim().toLowerCase() ?? '' return Object.hasOwn(WEIGHT, raw) ? WEIGHT[raw as Level] : WEIGHT.info -} +})() function timestamp(): string { const now = new Date() @@ -54,14 +54,18 @@ export function flushLogs(): Promise<void> { return writeChain } -async function rotate(file: string): Promise<void> { +async function rotate(file: string): Promise<boolean> { await rm(`${file}.${KEPT_ROTATED_FILES}`, { force: true }) for (let n = KEPT_ROTATED_FILES - 1; n >= 1; n--) { await rename(`${file}.${n}`, `${file}.${n + 1}`).catch(() => {}) } - await rename(file, `${file}.1`).catch(() => {}) + return rename(file, `${file}.1`).then( + () => true, + // a locked file (Windows: an antivirus, a viewer) must not silently pass for rotated + () => false + ) } async function appendToFile(file: string, line: string): Promise<void> { @@ -76,8 +80,12 @@ async function appendToFile(file: string, line: string): Promise<void> { const bytes = Buffer.byteLength(line) if (fileSize > 0 && fileSize + bytes > MAX_FILE_BYTES) { - await rotate(file) - fileSize = 0 + // when the rotation did not happen, re-read the real size: the log keeps growing, and the next + // line over the limit retries the rotation instead of counting a nonexistent empty file + fileSize = (await rotate(file)) ? 0 : await stat(file).then( + (info) => info.size, + () => 0 + ) } await appendFile(file, line, 'utf8') @@ -85,9 +93,10 @@ async function appendToFile(file: string, line: string): Promise<void> { } function write(level: Level, tag: string, msg: string): void { - if (WEIGHT[level] < minWeight()) return + if (WEIGHT[level] < MIN_WEIGHT) return - const line = `${timestamp()} ${level.toUpperCase().padEnd(5)} [${tag}] ${msg}` + // an outside string (a video title, a nickname) must not be able to forge log entries + const line = `${timestamp()} ${level.toUpperCase().padEnd(5)} [${tag}] ${msg.replace(/[\r\n]+/g, '\\n')}` if (level === 'error') console.error(line) else if (level === 'warn') console.warn(line) @@ -104,7 +113,6 @@ export function createLogger(tag: string) { return { debug: (msg: string) => write('debug', tag, msg), info: (msg: string) => write('info', tag, msg), - log: (msg: string) => write('info', tag, msg), warn: (msg: string) => write('warn', tag, msg), error: (msg: string) => write('error', tag, msg) } diff --git a/src/network.ts b/src/network.ts new file mode 100644 index 0000000..5ac7dfa --- /dev/null +++ b/src/network.ts @@ -0,0 +1,62 @@ +import os from 'node:os' + +// The one place that knows this machine's network identity: addresses, the hostname, the loopback question. +// Enumerating interfaces and reading the hostname are syscalls, and host-check asks on every request: +// everything here is cached with a short TTL, so the answers are one memory read while they are fresh. + +const ADDRESSES_TTL_MS = 5000 + +let cached: { at: number; addresses: Set<string>; lan: string[]; hostname: string } = { + at: 0, + addresses: new Set(), + lan: [], + hostname: '' +} + +function refresh(): void { + const now = Date.now() + if (now - cached.at < ADDRESSES_TTL_MS) return + + const addresses = new Set<string>() + const lan: string[] = [] + for (const interfaces of Object.values(os.networkInterfaces())) { + for (const iface of interfaces ?? []) { + addresses.add(withoutMappedPrefix(iface.address.split('%')[0])) + if (iface.family === 'IPv4' && !iface.internal) lan.push(iface.address) + } + } + + cached = { at: now, addresses, lan, hostname: os.hostname().toLowerCase() } +} + +export function withoutMappedPrefix(address: string): string { + return address.replace(/^::ffff:/i, '').toLowerCase() +} + +export function ownAddresses(): Set<string> { + refresh() + return cached.addresses +} + +/** The IPv4 addresses the outside world can point at this machine on. */ +export function lanAddresses(): string[] { + refresh() + return [...cached.lan] +} + +/** The computer's hostname, lowercase - what host-check compares the Host header against. */ +export function computerHostname(): string { + refresh() + return cached.hostname +} + +function isLoopbackAddress(normalized: string): boolean { + return normalized === '::1' || normalized.startsWith('127.') +} + +/** Whether the request socket address belongs to this machine: loopback or one of its own interfaces. */ +export function isLocalAddress(address: string | undefined): boolean { + if (!address) return false + const normalized = withoutMappedPrefix(address.split('%')[0]) + return isLoopbackAddress(normalized) || ownAddresses().has(normalized) +} diff --git a/src/persist.ts b/src/persist.ts index 5548c30..c4a31f2 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -21,7 +21,7 @@ export const cachePath = (name: string): string => join(getCacheDir(), name) export type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] } -const isPlain = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) +export const isPlain = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) const kindOf = (value: unknown): string => { if (value === null) return 'null' @@ -177,7 +177,7 @@ export async function ensureDataDirectory(): Promise<void> { const handle = await open(probe, 'w') await handle.close() await rm(probe, { force: true }) - log.log(`Data directory: ${dir}`) + log.info(`Data directory: ${dir}`) } catch (error) { log.error(`The data directory ${dir} is not writable: ${describeError(error)}. Nothing the app saves will survive a restart`) } diff --git a/src/player.ts b/src/player.ts index adadaaa..80be139 100644 --- a/src/player.ts +++ b/src/player.ts @@ -45,7 +45,7 @@ export function moveToNext(): QueueItem | null { const fallback = advanceFallback() setCurrent(fallback) - if (fallback) log.log(`started fallback: "${fallback.title}"`) + if (fallback) log.info(`started fallback: "${fallback.title}"`) return getCurrent() } @@ -57,9 +57,9 @@ function leaveCurrent(outcome: ItemOutcome): QueueItem | null { if (leaving) { if (outcome === 'skipped') { - log.log(`skipped "${leaving.title}"`) + log.info(`skipped "${leaving.title}"`) } else if (typeof outcome === 'object') { - if (outcome.code === 'BLOCKED') log.log(`current track was blocked, skipping: "${leaving.title}"`) + if (outcome.code === 'BLOCKED') log.info(`current track was blocked, skipping: "${leaving.title}"`) else { // a track that cannot be played is routine (the journal and the chat reply say why): an error entry would be noise log.warn(`playback failed: "${leaving.title}" (error ${outcome.params?.errorCode ?? 'unknown'})`) @@ -105,7 +105,7 @@ function isAboutCurrent(videoId?: string, playId?: number): boolean { // The player reports that a play of `videoId` finished. Returns false if that was not the current play and nothing changed export function endCurrent(videoId?: string, playId?: number): boolean { if (!isAboutCurrent(videoId, playId)) { - log.log(`ignored "ended" for ${videoId}: it is not the current track`) + log.info(`ignored "ended" for ${videoId}: it is not the current track`) return false } @@ -117,7 +117,7 @@ export function endCurrent(videoId?: string, playId?: number): boolean { // about another play: a stray report must not start the fallback on its own export function reportPlaybackFailure(errorCode?: number, videoId?: string, playId?: number): boolean { if (getCurrent() === null || !isAboutCurrent(videoId, playId)) { - log.log(`ignored playback failure for ${videoId ?? 'an unnamed track'}: it is not the current track`) + log.info(`ignored playback failure for ${videoId ?? 'an unnamed track'}: it is not the current track`) return false } diff --git a/src/queue.ts b/src/queue.ts index ec554ba..a527537 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -107,12 +107,12 @@ export function takeNextPlayable(): QueueItem | null { }) for (const item of closedOnTheWay) { - log.log(`skipped blocked track in queue: "${item.title}"`) + log.info(`skipped blocked track in queue: "${item.title}"`) finishItem(item, { code: 'BLOCKED' }) } const next = taken[0] ?? null - if (next) log.log(`moved to next: "${next.title}"`) + if (next) log.info(`moved to next: "${next.title}"`) return next } @@ -179,7 +179,7 @@ export function addSong(song: Song, requestedBy: string, options: AddOptions = { playing.update(() => { queue.push(item) }) - log.log(`added "${song.title}" at position ${queue.length}`) + log.info(`added "${song.title}" at position ${queue.length}`) } return { item, started } @@ -189,7 +189,7 @@ export function setCurrent(item: QueueItem | null): void { const previous = currentSong if (item?.videoId !== previous?.videoId) { - log.log(item ? `now playing "${item.title}"` : 'nothing is playing') + log.info(item ? `now playing "${item.title}"` : 'nothing is playing') } playing.update(() => { @@ -218,7 +218,7 @@ function removeAt(index: number): QueueItem | null { removed = item if (item) { - log.log(`removed "${item.title}" at position ${index + 1}`) + log.info(`removed "${item.title}" at position ${index + 1}`) finishItem(item, { code: 'TRACK_REMOVED' }) } }) @@ -235,7 +235,7 @@ export function clearQueue(): QueueItem[] { for (const item of cleared) finishItem(item, { code: 'QUEUE_CLEARED' }) }) - log.log(`cleared ${cleared.length} songs`) + log.info(`cleared ${cleared.length} songs`) return cleared } diff --git a/src/request.ts b/src/request.ts index 9cb195e..cc1803a 100644 --- a/src/request.ts +++ b/src/request.ts @@ -17,7 +17,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi assertCanRequestSong(requestedBy, { bypassFilters }) const { isYouTube, videoId } = parseYouTubeUrl(query) - log.log(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) + log.info(`[REQUEST] ${requestedBy} → ${videoId ? `YouTube URL: ${videoId}` : `Search: "${query}"`}`) if (isYouTube && !videoId) { throw new AppError('INVALID_YOUTUBE_URL') @@ -36,7 +36,7 @@ export async function requestSong(query: string, requestedBy: string, { bypassFi const { item, started } = addSong(song, requestedBy, { bypassFilters, channelPointsRedemption }) - log.log(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) + log.info(`[ACCEPT] ${requestedBy} → "${song.title}" - ${started ? 'now playing' : 'queued'}`) logAcceptance(requestedBy, query, song.title, song.videoId) return { song: item, started, position: started ? 0 : getQueue().length } diff --git a/src/routes/playlists.ts b/src/routes/playlists.ts index 2be322d..aab0442 100644 --- a/src/routes/playlists.ts +++ b/src/routes/playlists.ts @@ -2,7 +2,7 @@ import express from 'express' import { ConfigResponse, AppError } from '../types.js' import { ok } from '../http.js' import { getConfig, updateConfig } from '../config.js' -import { fetchPlaylistMeta } from '../youtube/index.js' +import { fetchPlaylistMeta } from '../youtube/mapper.js' import { getPlaylists, upsertPlaylist, removePlaylist, type PlaylistEntry } from '../playlists.js' import { clearFallback, settledConfigReaction } from '../fallback.js' import { createLogger } from '../logger.js' @@ -46,7 +46,7 @@ router.delete('/:id', (req, res) => { if (wasActive) { clearFallback() - log.log(`active playlist "${req.params.id}" removed - fallback cleared`) + log.info(`active playlist "${req.params.id}" removed - fallback cleared`) } ok(res, { playlists: playlistEntries(), fallbackCleared: wasActive }) @@ -63,7 +63,7 @@ router.post('/:id/activate', async (req, res) => { const previousId = getConfig().fallbackPlaylist.playlistId if (previousId === id) { clearFallback() - log.log(`deactivated playlist "${id}"`) + log.info(`deactivated playlist "${id}"`) return ok<ConfigResponse>(res, getConfig()) } diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 1be71a1..30ee66d 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -2,7 +2,7 @@ import express from 'express' import { StateResponse, QueueRemoveResponse, QueueRequestResponse, AppError } from '../types.js' import { ok } from '../http.js' import { wantsBypassFilters } from '../local-only.js' -import { checkQuery, validateVideoIdParam } from '../validate.js' +import { checkQuery, INPUT_LIMITS, validateVideoIdParam } from '../validate.js' import { t } from '../i18n.js' import { requestSong } from '../request.js' import { removeByVideoId, clearQueue } from '../queue.js' @@ -22,7 +22,7 @@ router.post('/request', async (req, res) => { throw new AppError('USERNAME_REQUIRED') } - const added = await requestSong(checkedQuery, requestedBy.trim().slice(0, 50), { bypassFilters }) + const added = await requestSong(checkedQuery, requestedBy.trim().slice(0, INPUT_LIMITS.requesterName), { bypassFilters }) const message = added.started ? t(getSettings().locale, 'toast.nowPlaying', { title: added.song.title }) diff --git a/src/routes/settings.ts b/src/routes/settings.ts index c7432d8..d52023c 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -1,7 +1,7 @@ import express from 'express' import { ConfigResponse, SettingsResponse } from '../types.js' import { ok, sendConfigUpdate } from '../http.js' -import { isLocalAddress, localOnly } from '../local-only.js' +import { isLocal, localOnly } from '../local-only.js' import { getConfig, updateConfig } from '../config.js' import { getSettings, updateSettings } from '../settings.js' import { getPublicSecretsView, SecretsResponse, updateSecrets } from '../secrets.js' @@ -37,7 +37,7 @@ router.put('/config', async (req, res) => { }) router.get('/secrets', (req, res) => { - ok<SecretsResponse>(res, getPublicSecretsView({ includeAuthorization: isLocalAddress(req.socket.remoteAddress) })) + ok<SecretsResponse>(res, getPublicSecretsView({ includeAuthorization: isLocal(req) })) }) router.put('/secrets', localOnly, (req, res) => { diff --git a/src/routes/system.ts b/src/routes/system.ts index d3047f4..f70edd0 100644 --- a/src/routes/system.ts +++ b/src/routes/system.ts @@ -1,19 +1,12 @@ import express from 'express' -import os from 'node:os' import { StateResponse, OverlayStateResponse } from '../types.js' import { ok } from '../http.js' import { getState } from '../player.js' import { getSettings } from '../settings.js' import { localOnly } from '../local-only.js' +import { lanAddresses } from '../network.js' import { shutdown } from '../shutdown.js' -export function lanAddresses(): string[] { - return Object.values(os.networkInterfaces()) - .flatMap((interfaces) => interfaces ?? []) - .filter((iface) => iface.family === 'IPv4' && !iface.internal) - .map((iface) => iface.address) -} - export function createSystemRouter({ exit = (code: number) => process.exit(code) }: { exit?: (code: number) => void } = {}): express.Router { const router = express.Router() diff --git a/src/routes/twitch.ts b/src/routes/twitch.ts index 8bd24d0..dc7459c 100644 --- a/src/routes/twitch.ts +++ b/src/routes/twitch.ts @@ -5,7 +5,7 @@ import { AppError } from '../types.js' import { parseRewardInput, toRewardDto } from '../integrations/twitch/rewards.js' import { startDeviceAuthorization, disconnect, refreshConnection, getClient, isTwitchConnected, getTwitchHealth } from '../integrations/twitch/index.js' import { getTwitchConfig, updateTwitchConfig } from '../integrations/twitch/config.js' -import { localOnly, isLocalAddress } from '../local-only.js' +import { isLocal, localOnly } from '../local-only.js' import { getPublicSecretsView } from '../secrets.js' import type { TwitchClient } from '../integrations/twitch/client.js' @@ -21,7 +21,7 @@ export const router = express.Router() // open on purpose (the panel on a phone or from a QR code reads it), so what only this computer may see is left out for the rest router.get('/', (req, res) => { - const twitch = getPublicSecretsView({ includeAuthorization: isLocalAddress(req.socket.remoteAddress) }).twitch + const twitch = getPublicSecretsView({ includeAuthorization: isLocal(req) }).twitch ok<TwitchConnectionResponse>(res, { ...twitch, health: getTwitchHealth() }) }) diff --git a/src/secrets.ts b/src/secrets.ts index ea05f03..7615563 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -1,5 +1,5 @@ import { dataPath, type DeepPartial } from './persist.js' -import { createConfigModule, field, type FieldRule, type Schema } from './config-helper.js' +import { createConfigModule, field, type DeepReadonly, type FieldRule, type Schema } from './config-helper.js' import { getTwitchClientId } from './env.js' import { AppError, type TwitchAuthorization, type TwitchSecrets, type TwitchTokenData, type TwitchUserInfo } from './types.js' @@ -54,23 +54,28 @@ const secrets = createConfigModule<Secrets>({ // the account (or its key) changed: the panel reads the connection again topics: ['twitch'], schema: { - // An empty string is allowed: it clears the key - youtubeApiKey: field({ normalize: (v) => (typeof v === 'string' ? v.trim() : v), validate: (v) => typeof v === 'string' }), + // An empty string is allowed: it clears the key. No format is enforced (YouTube's own has changed over the + // years), only a length cap, so a pasted blob cannot end up stored as "the key" + youtubeApiKey: field({ normalize: (v) => (typeof v === 'string' ? v.trim() : v), validate: (v) => typeof v === 'string' && v.length <= 200 }), twitch: TWITCH_SCHEMA } }) -// Secrets are refused as a whole: either everything in an update is valid, or nothing is applied +// Secrets are refused as a whole: either everything in an update is valid, or nothing is applied. +// Strict mode validates exactly once on the way in (updateConfig would validate a second time) function apply(updates: DeepPartial<Secrets>): void { - const { clean, rejected } = secrets.validateConfigUpdates(updates) + const { rejected } = secrets.updateConfigStrict(updates) if (rejected.length) throw new AppError('INVALID_INPUT', undefined, { fields: rejected.join(', ') }) - - secrets.updateConfig(clean) } -export const getSecrets = (): Secrets => secrets.getConfig() +export const getSecrets = (): DeepReadonly<Secrets> => secrets.getConfig() + +// Narrow getter: the YouTube layer needs one key, not the Twitch tokens that live in the same file +export function getYoutubeApiKey(): string { + return getSecrets().youtubeApiKey +} -export function updateSecrets(updates: SecretsUpdates): Secrets { +export function updateSecrets(updates: SecretsUpdates): DeepReadonly<Secrets> { if (updates.youtubeApiKey !== undefined) apply({ youtubeApiKey: updates.youtubeApiKey }) return getSecrets() diff --git a/src/server.ts b/src/server.ts index 2967f70..828f1c4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,11 +11,27 @@ import { apiRouter } from './routes/index.js' import { initializeTwitchIntegration } from './integrations/twitch/index.js' import { getTwitchClientId } from './env.js' import { clearTwitchAuthorization } from './secrets.js' -import { createLogger, describeError, enableFileLogging, flushLogs } from './logger.js' +import { createLogger, describeError, enableFileLogging } from './logger.js' import { hostCheck } from './host-check.js' -import { installShutdownHandlers } from './shutdown.js' +import { determineLocality } from './local-only.js' +import { installShutdownHandlers, registerServerStop, shutdown } from './shutdown.js' import type { Server } from 'node:http' +/** The assembled app: nothing runs on import, so a test can build the app without starting it. */ +export function createApp(): express.Express { + const app = express() + const publicDir = path.join(getAppRoot(), 'public') + + app.use(hostCheck) + app.use(determineLocality) + app.use(express.json({ limit: '50kb' })) + app.use(express.static(publicDir, { extensions: ['html'] })) + + app.use('/api', apiRouter) + app.use(errorHandler) + return app +} + async function listenOnFreePort(start: number): Promise<{ server: Server; port: number }> { for (let port = start; port < start + 100; port++) { try { @@ -32,19 +48,9 @@ async function listenOnFreePort(start: number): Promise<{ server: Server; port: throw new Error(`No free port in ${start}-${start + 99}`) } -const app = express() - -const PUBLIC_DIR = path.join(getAppRoot(), 'public') - -app.use(hostCheck) -app.use(express.json({ limit: '50kb' })) -app.use(express.static(PUBLIC_DIR, { extensions: ['html'] })) - +const app = createApp() const log = createLogger('SERVER') -app.use('/api', apiRouter) -app.use(errorHandler) - async function main() { loadEnvFile() enableFileLogging() @@ -62,22 +68,27 @@ async function main() { const preferredPort = getServerConfig().port const { server, port } = await listenOnFreePort(preferredPort) + // saved before the exit, a request accepted during the save would be lost after it + registerServerStop(() => server.close()) + if (port !== preferredPort) { // remembered, so the link does not change again on the next launch log.warn(`Port ${preferredPort} is busy, using ${port} instead. The overlay URL in OBS is now http://localhost:${port}/overlay`) updateServerConfig({ port: port }) } - log.log(`Server running on http://localhost:${port}`) + log.info(`Server running on http://localhost:${port}`) void runStartupTasks() server.on('error', (error) => { log.error(`Fatal server error: ${error.message}`) - void flushLogs().finally(() => process.exit(1)) + // one exit path: through shutdown(), so what changed since the last save is not lost + void shutdown(1) }) } main().catch((error) => { log.error(`Fatal: ${describeError(error)}`) - void flushLogs().finally(() => process.exit(1)) + // one exit path: through shutdown(), so what changed since the last save is not lost + void shutdown(1) }) diff --git a/src/shutdown.ts b/src/shutdown.ts index 967f991..1787bb6 100644 --- a/src/shutdown.ts +++ b/src/shutdown.ts @@ -6,6 +6,14 @@ const log = createLogger('SHUTDOWN') let shuttingDown = false +// The HTTP server registers itself at startup: shutdown stops accepting new requests before saving +let stopAcceptingRequests: (() => void) | null = null + +/** The one hook the HTTP server needs at shutdown: close the listener so no request lands after the save starts. */ +export function registerServerStop(stop: () => void): void { + stopAcceptingRequests = stop +} + // If saving never finishes (a stuck disk, a hung write) the process must still end: a crashed Node stays alive otherwise const FORCE_EXIT_AFTER_MS = 5000 @@ -17,9 +25,21 @@ export async function shutdown(code = 0, exit: (code: number) => void = (c) => p // first: an open stream holds the connection of the page, and nothing below depends on it closeAllStreams() + // the server stops taking new requests: one accepted after this point would change state + // just for its changes to be lost by the save below + stopAcceptingRequests?.() + + // the safety net owns the exit from the moment it fires: the normal path must not add a second one + // when its own save completes right after (a hung save can still finish, just too late) + let forcedExitStarted = false + const forceExit = setTimeout(() => { + forcedExitStarted = true log.error(`Saving did not finish in ${FORCE_EXIT_AFTER_MS / 1000}s, exiting anyway`) - exit(code) + // what already made it to the write chain is flushed, but a stuck chain must not hold the exit hostage + // (a plain global setTimeout, so the guard is as mockable as the timer that starts it) + const boundedFlush = new Promise<void>((resolve) => setTimeout(resolve, 1000)) + void Promise.race([flushLogs(), boundedFlush]).finally(() => exit(code)) }, FORCE_EXIT_AFTER_MS) forceExit.unref() @@ -33,7 +53,7 @@ export async function shutdown(code = 0, exit: (code: number) => void = (c) => p // saved in time: the safety net must not fire a second exit later clearTimeout(forceExit) - exit(code) + if (!forcedExitStarted) exit(code) } /** SIGINT (Ctrl+C), SIGTERM (a service stop), SIGHUP (Windows sends it when the console window is closed), SIGBREAK and a crash all end the same way: saved first. */ @@ -45,13 +65,15 @@ export function installShutdownHandlers(): void { for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) { process.on(signal, () => { - log.log(`${signal} received, saving and exiting`) + log.info(`${signal} received, saving and exiting`) void shutdown(0) }) } process.on('uncaughtException', (error) => { + // the same policy as a rejected promise (the owner's decision): a stray exception in a callback must not + // stop the music mid-stream. The line is flushed so it survives even if the app dies later after all log.error(`Uncaught exception: ${describeError(error, true)}`) - void shutdown(1) + void flushLogs() }) } diff --git a/src/state-file.ts b/src/state-file.ts index 713a56e..1e2acdc 100644 --- a/src/state-file.ts +++ b/src/state-file.ts @@ -1,4 +1,4 @@ -import { cachePath, createFileStore, dataPath } from './persist.js' +import { cachePath, createFileStore, dataPath, isPlain } from './persist.js' import { QueueItem, Song } from './types.js' import { isValidPlaylistId, isValidVideoId } from './youtube/url.js' import { getQueue, getCurrent, hydrateQueue } from './queue.js' @@ -20,12 +20,11 @@ export type SanitizedState = { problems: string[] } -const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value) const text = (value: unknown): string => (typeof value === 'string' ? value : '') const amount = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0) export function sanitizeSong(raw: unknown): Song | null { - if (!isObject(raw) || !isValidVideoId(raw.videoId) || !text(raw.title)) return null + if (!isPlain(raw) || !isValidVideoId(raw.videoId) || !text(raw.title)) return null const videoId = raw.videoId @@ -41,7 +40,7 @@ export function sanitizeSong(raw: unknown): Song | null { } function sanitizeRedemption(raw: unknown): QueueItem['channelPointsRedemption'] { - if (!isObject(raw)) return undefined + if (!isPlain(raw)) return undefined const id = text(raw.id).trim() const rewardId = text(raw.rewardId).trim() @@ -52,19 +51,19 @@ function sanitizeRedemption(raw: unknown): QueueItem['channelPointsRedemption'] export function sanitizeQueueItem(raw: unknown): QueueItem | null { const song = sanitizeSong(raw) - const requestedBy = isObject(raw) ? text(raw.requestedBy).trim() : '' + const requestedBy = isPlain(raw) ? text(raw.requestedBy).trim() : '' if (!song || !requestedBy) return null // without the redemption a restored track would play, but its Channel Points reward would never be closed - const redemption = isObject(raw) ? sanitizeRedemption(raw.channelPointsRedemption) : undefined + const redemption = isPlain(raw) ? sanitizeRedemption(raw.channelPointsRedemption) : undefined // the play id survives a restart, so a report that arrives late still names the play it belongs to - const playId = isObject(raw) && Number.isInteger(raw.playId) && (raw.playId as number) > 0 ? (raw.playId as number) : undefined + const playId = isPlain(raw) && Number.isInteger(raw.playId) && (raw.playId as number) > 0 ? (raw.playId as number) : undefined return { ...song, requestedBy, // the flag is read as written: a name alone never marks a track, for a viewer may go by it too - ...(isObject(raw) && raw.isFallback === true ? { isFallback: true } : {}), + ...(isPlain(raw) && raw.isFallback === true ? { isFallback: true } : {}), ...(playId !== undefined ? { playId } : {}), ...(redemption ? { channelPointsRedemption: redemption } : {}) } @@ -72,7 +71,7 @@ export function sanitizeQueueItem(raw: unknown): QueueItem | null { function sanitizeFallback(raw: unknown, problems: string[]): FallbackSnapshot | undefined { if (raw === undefined) return undefined - if (!isObject(raw)) { + if (!isPlain(raw)) { problems.push('fallback is not an object, ignored') return undefined } @@ -89,6 +88,7 @@ function sanitizeFallback(raw: unknown, problems: string[]): FallbackSnapshot | const order = [...new Set(rawOrder.filter((id): id is string => typeof id === 'string'))] const clean = order.length === rawOrder.length + // the saved order is kept only whole: a damaged one is dropped, and the load rebuilds it from the tracks if (!clean) problems.push('fallback: rotation is damaged, reset') const rawCursor = raw.cursor @@ -99,7 +99,7 @@ function sanitizeFallback(raw: unknown, problems: string[]): FallbackSnapshot | return { sourceTracks: [...tracks.values()], - order, + order: clean ? order : [], cursor, playlistId: isValidPlaylistId(raw.playlistId) ? raw.playlistId : null, lastRefreshedAt: typeof lastRefreshedAt === 'number' && Number.isFinite(lastRefreshedAt) ? lastRefreshedAt : null, @@ -110,10 +110,10 @@ function sanitizeFallback(raw: unknown, problems: string[]): FallbackSnapshot | // Turns whatever was read from disk into something safe to load. Every part is repaired on its own: // a bad queue item, a bad setting or a bad fallback entry costs only itself, never the other parts export function sanitizeState(raw: unknown): SanitizedState { - const data = isObject(raw) ? raw : {} + const data = isPlain(raw) ? raw : {} const problems: string[] = [] - if (!isObject(raw)) problems.push('state file is not an object, using defaults') + if (!isPlain(raw)) problems.push('state file is not an object, using defaults') let current: QueueItem | null = null if (data.current !== undefined && data.current !== null) { @@ -194,7 +194,7 @@ export function loadState(): void { const tracks = tracksStore.load({ sourceTracks: [] }).sourceTracks // an older state file still carries the tracks inside `fallback`: they are used until the track file exists - const raw = tracks.length > 0 && isObject(saved.fallback) ? { ...saved, fallback: { ...saved.fallback, sourceTracks: tracks } } : saved + const raw = tracks.length > 0 && isPlain(saved.fallback) ? { ...saved, fallback: { ...saved.fallback, sourceTracks: tracks } } : saved const { current, queue, fallback, problems } = sanitizeState(raw) for (const problem of problems) log.warn(problem) @@ -205,7 +205,7 @@ export function loadState(): void { savedTracksVersion = tracks.length > 0 ? getFallbackSnapshot().sourceVersion : -1 - log.log('State loaded from disk') + log.info('State loaded from disk') } let initialized = false diff --git a/src/types.ts b/src/types.ts index 66be0d1..97cb5e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export type HttpErrorCode = // Why a track ended without playing through (removed, cleared, blocked, playback failure) or an open reward // was left: these never answer an HTTP request, they only name activity entries and chat replies export type ChatReasonCode = + | 'TWITCH_CONNECTION_LOST' | 'PLAYBACK_VIDEO_UNAVAILABLE' | 'PLAYBACK_EMBED_DISALLOWED' | 'PLAYBACK_FAILED' diff --git a/src/validate.ts b/src/validate.ts index 233fa3f..9ccc5d4 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -5,6 +5,17 @@ import { isValidPlaylistId, isValidVideoId, parsePlaylistId } from './youtube/ur // The shared request rules, so two routes that take the same kind of input cannot drift apart: // every route with a `:videoId` param goes through validateVideoIdParam, every query through checkQuery +// One home for the input caps, so a limit cannot drift between the routes, the chat and the rewards +export const INPUT_LIMITS = { + queryMin: 2, + queryMax: 200, + requesterName: 50, + blocklistTitle: 200, + chatMessage: 500, + rewardTitle: 45, + rewardPrompt: 200 +} as const + export function checkVideoId(value: unknown): asserts value is string { if (!isValidVideoId(value)) throw new AppError('INVALID_VIDEO_ID') } @@ -23,8 +34,8 @@ export function checkPlaylistIdOrUrl(value: unknown): string { export function checkQuery(value: unknown): string { const query = typeof value === 'string' ? value.trim() : '' - if (query.length > 200) throw new AppError('INVALID_QUERY_RANGE') - if (query.length < 2) throw new AppError('INVALID_QUERY') + if (query.length > INPUT_LIMITS.queryMax) throw new AppError('INVALID_QUERY_RANGE') + if (query.length < INPUT_LIMITS.queryMin) throw new AppError('INVALID_QUERY') return query } diff --git a/src/web-fetch.ts b/src/web-fetch.ts new file mode 100644 index 0000000..c8edda4 --- /dev/null +++ b/src/web-fetch.ts @@ -0,0 +1,40 @@ +import { setTimeout as sleep } from 'node:timers/promises' + +export type RetryRule = { + attempts: number + // the pause before the next attempt: the caller decides how fast the delay grows + delayMs: (attempt: number) => number + shouldRetry: (failure: { error?: unknown; status?: number }) => boolean +} + +/** + * One retry rule for the transports: what can pass by itself - a network error, a timeout (they arrive + * as the error half of the failure), a 429, a 5xx. What a failure MEANS stays with the caller + * (a refused request is still an error to answer with); only the "worth trying again" question is shared. + */ +export const retryableTransport = (failure: { error?: unknown; status?: number }): boolean => + failure.error !== undefined || failure.status === 429 || (typeof failure.status === 'number' && failure.status >= 500) + +/** + * The one fetch skeleton: a request with a timeout, retried while attempts are left and the rule says + * the failure can pass by itself. Only the transport lives here; parsing the answer and naming a + * failure stay with the caller (the YouTube client has its quota, the Twitch layer its own codes). + */ +export async function fetchWithTimeout(url: string | URL, init: RequestInit | undefined, timeoutMs: number, retry?: RetryRule): Promise<Response> { + for (let attempt = 1; ; attempt++) { + try { + const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }) + if (retry && attempt < retry.attempts && retry.shouldRetry({ status: response.status })) { + await sleep(retry.delayMs(attempt)) + continue + } + return response + } catch (error) { + if (retry && attempt < retry.attempts && retry.shouldRetry({ error })) { + await sleep(retry.delayMs(attempt)) + continue + } + throw error + } + } +} diff --git a/src/youtube/client.ts b/src/youtube/client.ts index 3b7c6b0..0b61a0d 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -1,18 +1,11 @@ -import { getSecrets } from '../secrets.js' +import { getYoutubeApiKey } from '../secrets.js' import { describeError } from '../logger.js' -import { Song, AppError, Config, FilterFailureReason } from '../types.js' +import { AppError } from '../types.js' import { VideoItem } from './types.js' -import { isoDurationToSeconds } from './utils.js' import { isQuotaExhausted, markQuotaExhausted } from './cache.js' - -export type PlaylistMeta = { - id: string - title: string - thumbnail: string - itemCount: number -} - -export type PlaylistFetchResult = { songs: Song[]; truncated: boolean; itemCount: number | null } +import { isPlain } from '../persist.js' +import { VIDEO_DETAILS_PART, isMusicVideo, isAvailableInRegion, isLikelyShort } from './filters.js' +import { fetchWithTimeout, retryableTransport } from '../web-fetch.js' type YouTubeErrorResponse = { error?: { @@ -22,125 +15,12 @@ type YouTubeErrorResponse = { } } -type FilterRule = { - reason: FilterFailureReason - guard?: boolean - check: (song: Song, video: VideoItem, config: Config) => boolean - params?: (config: Config) => Record<string, string | number> | undefined -} - -const SHORTS_MAX_DURATION_SECONDS = 180 - -export const VIDEO_DETAILS_PART = 'snippet,contentDetails,statistics,status,topicDetails' - -// YouTube's own category for music -export const MUSIC_CATEGORY_ID = '10' - -// The last part of the Wikipedia topic links YouTube uses for music (topicDetails.topicCategories). -// Many official clips are filed by the uploader under another category (Entertainment, People & Blogs) but still carry one of these -const MUSIC_TOPICS = new Set([ - 'music', - 'christian_music', - 'classical_music', - 'country_music', - 'electronic_music', - 'hip_hop_music', - 'independent_music', - 'jazz', - 'music_of_asia', - 'music_of_latin_america', - 'pop_music', - 'reggae', - 'rhythm_and_blues', - 'rock_music', - 'soul_music' -]) - -export function isMusicVideo(video: VideoItem): boolean { - if (video.snippet?.categoryId === MUSIC_CATEGORY_ID) return true - - return (video.topicDetails?.topicCategories ?? []).some((link) => { - const topic = link.split('/').pop()?.toLowerCase() - return topic !== undefined && MUSIC_TOPICS.has(topic) - }) -} - -// 'live' or 'upcoming'; its duration is not known yet, so the duration rules do not apply to it -const isUpcoming = (video: VideoItem): boolean => video.snippet?.liveBroadcastContent === 'upcoming' - -// 'none' means the video is not a broadcast; anything else is one -const isLiveBroadcast = (video: VideoItem): boolean => { - const live = video.snippet?.liveBroadcastContent - return live !== undefined && live !== 'none' -} - -const isNotPublic = (video: VideoItem): boolean => { - const privacy = video.status?.privacyStatus - return privacy !== undefined && privacy !== 'public' -} - -const isNotProcessed = (video: VideoItem): boolean => { - const upload = video.status?.uploadStatus - return upload !== undefined && upload !== 'processed' -} - -const FILTER_RULES: FilterRule[] = [ - { - reason: 'NOT_MUSIC', - check: (_s, v, c) => c.contentMode === 'music' && !isMusicVideo(v) - }, - { - reason: 'NOT_PUBLIC', - guard: true, - check: (_s, v) => isNotPublic(v) - }, - { - reason: 'NOT_EMBEDDABLE', - guard: true, - check: (_s, v) => v.status?.embeddable === false - }, - { - reason: 'REGION_BLOCKED', - guard: true, - check: (_s, v, c) => !isAvailableInRegion(v, c.regionCode) - }, - { - reason: 'AGE_RESTRICTED', - guard: true, - check: (_s, v) => v.contentDetails?.contentRating?.ytRating === 'ytAgeRestricted' - }, - { - // a scheduled stream or a premiere has nothing to play yet, whatever the setting for live streams says - reason: 'NOT_PLAYABLE', - guard: true, - check: (_s, v) => isNotProcessed(v) || isUpcoming(v) - }, - { - reason: 'IS_LIVE', - check: (_s, v, c) => !c.allowLiveStreams && isLiveBroadcast(v) - }, - { - reason: 'VIEWS_TOO_LOW', - check: (s, _v, c) => s.views < c.minViews, - params: (c) => ({ min: c.minViews }) - }, - { - reason: 'DURATION_OUT_OF_RANGE', - check: (s, v, c) => !isLiveBroadcast(v) && (s.duration < c.minDurationSeconds || s.duration > c.maxDurationSeconds), - params: (c) => ({ min: c.minDurationSeconds, max: c.maxDurationSeconds }) - }, - { - reason: 'IS_SHORT', - check: (s, _v, c) => !c.allowShorts && isLikelyShort(s.duration) - } -] - const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]) const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 500 export async function youtube<T>(path: string, params: Record<string, string>, { attempts = MAX_ATTEMPTS }: { attempts?: number } = {}): Promise<T> { - const { youtubeApiKey } = getSecrets() + const youtubeApiKey = getYoutubeApiKey() if (!youtubeApiKey) throw new AppError('NO_API_KEY') if (isQuotaExhausted()) throw new AppError('YOUTUBE_QUOTA') @@ -152,96 +32,35 @@ export async function youtube<T>(path: string, params: Record<string, string>, { } // a timeout, a network error, a 429 and a 5xx are tried again (with growing pauses) while attempts are left - for (let attempt = 1; ; attempt++) { - let response: Response - try { - response = await fetch(url, { signal: AbortSignal.timeout(10_000) }) - } catch (error) { - if (attempt >= attempts) { - const message = `YouTube API is unreachable: ${describeError(error)}` - throw new AppError('YOUTUBE_ERROR', message) - } - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * attempt)) - continue - } - - if (RETRY_STATUSES.has(response.status) && attempt < attempts) { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * attempt)) - continue - } + let response: Response + try { + response = await fetchWithTimeout(url, undefined, 10_000, { + attempts, + delayMs: (attempt) => RETRY_DELAY_MS * attempt, + shouldRetry: retryableTransport + }) + } catch (error) { + throw new AppError('YOUTUBE_ERROR', `YouTube API is unreachable: ${describeError(error)}`) + } - // an error page is not always JSON - const data = (await response.json().catch(() => ({}))) as T & YouTubeErrorResponse + // an error page is not always JSON, and a 200 is not always an object either: the caller maps the + // answer right after, so the promise of an AppError holds for a malformed body as well + const data = (await response.json().catch(() => ({}))) as T & YouTubeErrorResponse - if (!response.ok) { - const reason = data.error?.errors?.[0]?.reason + if (!isPlain(data)) throw new AppError('YOUTUBE_ERROR', 'YouTube API returned a malformed response') - if (reason === 'quotaExceeded') { - markQuotaExhausted() - throw new AppError('YOUTUBE_QUOTA') - } + if (!response.ok) { + const reason = data.error?.errors?.[0]?.reason - throw new AppError('YOUTUBE_ERROR', data.error?.message || `YouTube API error ${response.status}`) + if (reason === 'quotaExceeded') { + markQuotaExhausted() + throw new AppError('YOUTUBE_QUOTA') } - return data - } -} - -export function videoToSong(video: VideoItem): Song { - return { - videoId: video.id, - title: video.snippet?.title ?? 'Unknown title', - channelTitle: video.snippet?.channelTitle ?? 'Unknown channel', - thumbnail: video.snippet?.thumbnails?.medium?.url ?? '', - duration: isoDurationToSeconds(video.contentDetails?.duration), - views: Number(video.statistics?.viewCount ?? 0), - url: `https://www.youtube.com/watch?v=${video.id}` - } -} - -export async function fetchPlaylistMeta(playlistId: string): Promise<PlaylistMeta | null> { - const data = await youtube<{ - items: Array<{ id: string; snippet?: { title?: string; thumbnails?: { medium?: { url?: string } } }; contentDetails?: { itemCount?: number } }> - }>('playlists', { part: 'snippet,contentDetails', id: playlistId }) - - const item = data.items?.[0] - if (!item) return null - - return { - id: item.id, - title: item.snippet?.title ?? 'Untitled playlist', - thumbnail: item.snippet?.thumbnails?.medium?.url ?? '', - itemCount: item.contentDetails?.itemCount ?? 0 + throw new AppError('YOUTUBE_ERROR', data.error?.message || `YouTube API error ${response.status}`) } -} - -export function isAvailableInRegion(video: VideoItem, regionCode: string): boolean { - if (!regionCode) return true - - const restriction = video.contentDetails?.regionRestriction - if (!restriction) return true - - if (restriction.blocked?.includes(regionCode)) return false - if (restriction.allowed && (restriction.allowed.length === 0 || !restriction.allowed.includes(regionCode))) return false - - return true -} - -function isLikelyShort(durationSeconds: number): boolean { - return durationSeconds > 0 && durationSeconds <= SHORTS_MAX_DURATION_SECONDS -} -/** - * The one filter check: the first rule a track fails becomes the error the API and the chat answer with. - * `guardOnly` limits the check to the guard rules: the streamer's own playlist answers to those alone, what - * the viewer rules would trim away there is the streamer's own choice. A rule's user-facing text lives in - * the locales and is looked up by the code, so the error carries only the params a rule's translation needs - */ -export function checkFilters(song: Song, video: VideoItem, config: Config, guardOnly = false): AppError | null { - const rules = guardOnly ? FILTER_RULES.filter((rule) => rule.guard) : FILTER_RULES - const rule = rules.find((rule) => rule.check(song, video, config)) - return rule ? new AppError(rule.reason, undefined, rule.params?.(config)) : null + return data } const BATCH_SIZE = 50 diff --git a/src/youtube/filters.ts b/src/youtube/filters.ts new file mode 100644 index 0000000..4a9cc54 --- /dev/null +++ b/src/youtube/filters.ts @@ -0,0 +1,146 @@ +import { Song, AppError, Config, FilterFailureReason } from '../types.js' +import { VideoItem } from './types.js' + +type FilterRule = { + reason: FilterFailureReason + guard?: boolean + check: (song: Song, video: VideoItem, config: Config) => boolean + params?: (config: Config) => Record<string, string | number> | undefined +} + +// A duration alone cannot tell a Short from a short song: only the lowest bound is safe to call a Short. +// Anything longer is a regular track; the streamer who wants short content gone raises minDurationSeconds. +const SHORTS_MAX_DURATION_SECONDS = 180 + +// The parts the video fetch asks for: what the rules below read (plus what the mapper shows) +export const VIDEO_DETAILS_PART = 'snippet,contentDetails,statistics,status,topicDetails' + +// YouTube's own category for music +export const MUSIC_CATEGORY_ID = '10' + +// The last part of the Wikipedia topic links YouTube uses for music (topicDetails.topicCategories). +// Many official clips are filed by the uploader under another category (Entertainment, People & Blogs) but still carry one of these +const MUSIC_TOPICS = new Set([ + 'music', + 'christian_music', + 'classical_music', + 'country_music', + 'electronic_music', + 'hip_hop_music', + 'independent_music', + 'jazz', + 'music_of_asia', + 'music_of_latin_america', + 'pop_music', + 'reggae', + 'rhythm_and_blues', + 'rock_music', + 'soul_music' +]) + +export function isMusicVideo(video: VideoItem): boolean { + if (video.snippet?.categoryId === MUSIC_CATEGORY_ID) return true + + return (video.topicDetails?.topicCategories ?? []).some((link) => { + const topic = link.split('/').pop()?.toLowerCase() + return topic !== undefined && MUSIC_TOPICS.has(topic) + }) +} + +// 'live' or 'upcoming'; its duration is not known yet, so the duration rules do not apply to it +const isUpcoming = (video: VideoItem): boolean => video.snippet?.liveBroadcastContent === 'upcoming' + +// 'none' means the video is not a broadcast; anything else is one +const isLiveBroadcast = (video: VideoItem): boolean => { + const live = video.snippet?.liveBroadcastContent + return live !== undefined && live !== 'none' +} + +const isNotPublic = (video: VideoItem): boolean => { + const privacy = video.status?.privacyStatus + return privacy !== undefined && privacy !== 'public' +} + +const isNotProcessed = (video: VideoItem): boolean => { + const upload = video.status?.uploadStatus + return upload !== undefined && upload !== 'processed' +} + +const FILTER_RULES: FilterRule[] = [ + { + reason: 'NOT_MUSIC', + check: (_s, v, c) => c.contentMode === 'music' && !isMusicVideo(v) + }, + { + reason: 'NOT_PUBLIC', + guard: true, + check: (_s, v) => isNotPublic(v) + }, + { + reason: 'NOT_EMBEDDABLE', + guard: true, + check: (_s, v) => v.status?.embeddable === false + }, + { + reason: 'REGION_BLOCKED', + guard: true, + check: (_s, v, c) => !isAvailableInRegion(v, c.regionCode) + }, + { + reason: 'AGE_RESTRICTED', + guard: true, + check: (_s, v) => v.contentDetails?.contentRating?.ytRating === 'ytAgeRestricted' + }, + { + // a scheduled stream or a premiere has nothing to play yet, whatever the setting for live streams says + reason: 'NOT_PLAYABLE', + guard: true, + check: (_s, v) => isNotProcessed(v) || isUpcoming(v) + }, + { + reason: 'IS_LIVE', + check: (_s, v, c) => !c.allowLiveStreams && isLiveBroadcast(v) + }, + { + reason: 'VIEWS_TOO_LOW', + check: (s, _v, c) => s.views < c.minViews, + params: (c) => ({ min: c.minViews }) + }, + { + reason: 'DURATION_OUT_OF_RANGE', + check: (s, v, c) => !isLiveBroadcast(v) && (s.duration < c.minDurationSeconds || s.duration > c.maxDurationSeconds), + params: (c) => ({ min: c.minDurationSeconds, max: c.maxDurationSeconds }) + }, + { + reason: 'IS_SHORT', + check: (s, _v, c) => !c.allowShorts && isLikelyShort(s.duration) + } +] + +export function isAvailableInRegion(video: VideoItem, regionCode: string): boolean { + if (!regionCode) return true + + const restriction = video.contentDetails?.regionRestriction + if (!restriction) return true + + if (restriction.blocked?.includes(regionCode)) return false + if (restriction.allowed && (restriction.allowed.length === 0 || !restriction.allowed.includes(regionCode))) return false + + return true +} + +function isLikelyShort(durationSeconds: number): boolean { + return durationSeconds > 0 && durationSeconds <= SHORTS_MAX_DURATION_SECONDS +} + +/** + * The one filter check: the first rule a track fails becomes the error the API and the chat answer with. + * `guardOnly` limits the check to the guard rules: the streamer's own playlist answers to those alone, what + * the viewer rules would trim away there is the streamer's own choice. A rule's user-facing text lives in + * the locales and is looked up by the code, so the error carries only the params a rule's translation needs + */ +export function checkFilters(song: Song, video: VideoItem, config: Config, guardOnly = false): AppError | null { + const rules = guardOnly ? FILTER_RULES.filter((rule) => rule.guard) : FILTER_RULES + const rule = rules.find((rule) => rule.check(song, video, config)) + return rule ? new AppError(rule.reason, undefined, rule.params?.(config)) : null +} diff --git a/src/youtube/index.ts b/src/youtube/index.ts index 7946733..37b7ace 100644 --- a/src/youtube/index.ts +++ b/src/youtube/index.ts @@ -1,14 +1,7 @@ -import { Song, AppError } from '../types.js' -import { - youtube, - videoToSong, - checkFilters, - fetchPlaylistMeta, - PlaylistMeta, - MUSIC_CATEGORY_ID, - PlaylistFetchResult, - fetchVideos -} from './client.js' +import { Song } from '../types.js' +import { youtube, fetchVideos } from './client.js' +import { checkFilters, MUSIC_CATEGORY_ID } from './filters.js' +import { videoToSong, fetchPlaylistMeta, type PlaylistFetchResult } from './mapper.js' import { normalize } from './utils.js' import { getSearchCache, setSearchCache, getVideoCache, setVideoCache } from './cache.js' import { VideoItem, SearchItem, PlaylistItem } from './types.js' @@ -23,15 +16,6 @@ const MAX_PLAYLIST_PAGES = 5 const log = createLogger('YOUTUBE') -async function withYouTubeErrorHandling<T>(label: string, fn: () => Promise<T>): Promise<T> { - try { - return await fn() - } catch (error) { - if (error instanceof AppError) throw error - throw new AppError('YOUTUBE_ERROR', `${label} failed: ${describeError(error)}`) - } -} - function dedupInFlight<T>(pending: Map<string, Promise<T>>, key: string, run: () => Promise<T>): Promise<T> { const existing = pending.get(key) if (existing) return existing @@ -62,7 +46,9 @@ export async function getVideoById(videoId: string, bypassFilters = false): Prom } async function fetchVideoById(videoId: string): Promise<VideoItem | null> { - const [video] = await withYouTubeErrorHandling('YouTube API', () => fetchVideos([videoId])) + // youtube() turns every failure into an AppError already, and the flow that owns the request + // (a song request, a playlist load) logs it at its own level - nothing to re-wrap here + const [video] = await fetchVideos([videoId]) setVideoCache(videoId, video ?? null) return video ?? null @@ -134,9 +120,6 @@ export function selectBestSong(songs: Song[], isAvailable: (song: Song) => boole return selected } -export { fetchPlaylistMeta } -export type { PlaylistMeta, PlaylistFetchResult } - export async function fetchPlaylistSongs(playlistId: string): Promise<PlaylistFetchResult> { if (!playlistId) { return { songs: [], truncated: false, itemCount: null } @@ -146,37 +129,40 @@ export async function fetchPlaylistSongs(playlistId: string): Promise<PlaylistFe } async function performPlaylistFetch(playlistId: string): Promise<PlaylistFetchResult> { - return withYouTubeErrorHandling('Playlist fetch', async () => { - const ids = new Set<string>() - let pageToken: string | undefined - let truncated = false - - for (let page = 1; ; page++) { - const playlist = await youtube<{ items?: PlaylistItem[]; nextPageToken?: string }>('playlistItems', { - part: 'snippet', - playlistId, - maxResults: String(PLAYLIST_PAGE_SIZE), - ...(pageToken ? { pageToken } : {}) - }) - - for (const item of playlist.items ?? []) { - const id = item.snippet?.resourceId?.videoId - if (id) ids.add(id) - } - - pageToken = playlist.nextPageToken - if (!pageToken) break - - if (page >= MAX_PLAYLIST_PAGES) { - log.warn(`Playlist: maximum page limit reached (${MAX_PLAYLIST_PAGES}), the rest is not loaded`) - truncated = true - break - } + const ids = new Set<string>() + let pageToken: string | undefined + let truncated = false + + for (let page = 1; ; page++) { + const playlist = await youtube<{ items?: PlaylistItem[]; nextPageToken?: string }>('playlistItems', { + part: 'snippet', + playlistId, + maxResults: String(PLAYLIST_PAGE_SIZE), + ...(pageToken ? { pageToken } : {}) + }) + + for (const item of playlist.items ?? []) { + const id = item.snippet?.resourceId?.videoId + if (id) ids.add(id) } - const meta = await fetchPlaylistMeta(playlistId) - const songs = mapValidSongs(await fetchVideos([...ids]), 'guard') - log.log(`Playlist: ${songs.length} tracks loaded`) - return { songs, truncated, itemCount: meta?.itemCount ?? null } + pageToken = playlist.nextPageToken + if (!pageToken) break + + if (page >= MAX_PLAYLIST_PAGES) { + log.warn(`Playlist: maximum page limit reached (${MAX_PLAYLIST_PAGES}), the rest is not loaded`) + truncated = true + break + } + } + + // the declared size only decorates the panel's "N of M": asked for alongside the videos, best-effort, + // so an endpoint with nothing to say takes the number away, never the loaded tracks + const meta = fetchPlaylistMeta(playlistId).catch((error) => { + log.warn(`Playlist: the declared size is unavailable: ${describeError(error)}`) + return null }) + const songs = mapValidSongs(await fetchVideos([...ids]), 'guard') + log.info(`Playlist: ${songs.length} tracks loaded`) + return { songs, truncated, itemCount: (await meta)?.itemCount ?? null } } diff --git a/src/youtube/mapper.ts b/src/youtube/mapper.ts new file mode 100644 index 0000000..e4b9279 --- /dev/null +++ b/src/youtube/mapper.ts @@ -0,0 +1,43 @@ +import { Song } from '../types.js' +import { VideoItem, Snippet } from './types.js' +import { isoDurationToSeconds } from './utils.js' +import { youtube } from './client.js' + +export type PlaylistMeta = { + id: string + title: string + thumbnail: string + itemCount: number +} + +export type PlaylistFetchResult = { songs: Song[]; truncated: boolean; itemCount: number | null } + +// A missing snippet field is stored as an empty string: the panel puts its own localized placeholder on top +// of an empty title (common.unknownTitle in the locales), so no fixed-language text travels with the data +export function videoToSong(video: VideoItem): Song { + return { + videoId: video.id, + title: video.snippet?.title ?? '', + channelTitle: video.snippet?.channelTitle ?? '', + thumbnail: video.snippet?.thumbnails?.medium?.url ?? '', + duration: isoDurationToSeconds(video.contentDetails?.duration), + views: Number(video.statistics?.viewCount ?? 0), + url: `https://www.youtube.com/watch?v=${video.id}` + } +} + +export async function fetchPlaylistMeta(playlistId: string): Promise<PlaylistMeta | null> { + const data = await youtube<{ + items?: Array<{ id: string; snippet?: Snippet; contentDetails?: { itemCount?: number } }> + }>('playlists', { part: 'snippet,contentDetails', id: playlistId }) + + const item = data.items?.[0] + if (!item) return null + + return { + id: item.id, + title: item.snippet?.title ?? '', + thumbnail: item.snippet?.thumbnails?.medium?.url ?? '', + itemCount: item.contentDetails?.itemCount ?? 0 + } +} diff --git a/src/youtube/types.ts b/src/youtube/types.ts index 5eb9a44..8d975d8 100644 --- a/src/youtube/types.ts +++ b/src/youtube/types.ts @@ -1,31 +1,29 @@ -export type PlaylistItem = { - snippet?: { - resourceId?: { - videoId?: string - } - title?: string - channelTitle?: string - thumbnails?: { - medium?: { - url?: string - } +// One snippet shape: YouTube sends the same object for search results, playlist items and videos. +// Every field is optional, because a part can be missing from the response, and each consumer reads only its own. +export type Snippet = { + title?: string + channelTitle?: string + thumbnails?: { + medium?: { + url?: string } } + categoryId?: string + // 'live' | 'upcoming' | 'none' + liveBroadcastContent?: string + // playlist items carry the video they point at here + resourceId?: { + videoId?: string + } +} + +export type PlaylistItem = { + snippet?: Snippet } export type VideoItem = { id: string - snippet?: { - title?: string - channelTitle?: string - thumbnails?: { - medium?: { - url?: string - } - } - categoryId?: string - liveBroadcastContent?: string // 'live' | 'upcoming' | 'none' - } + snippet?: Snippet topicDetails?: { topicCategories?: string[] } @@ -51,13 +49,5 @@ export type SearchItem = { id?: { videoId?: string } - snippet?: { - title?: string - channelTitle?: string - thumbnails?: { - medium?: { - url?: string - } - } - } + snippet?: Snippet } diff --git a/tests/backend/config.test.ts b/tests/backend/config.test.ts index 498e472..58669a5 100644 --- a/tests/backend/config.test.ts +++ b/tests/backend/config.test.ts @@ -123,13 +123,11 @@ test('updateConfig()', async (t) => { assert.deepEqual(getConfig().fallbackPlaylist, before) }) - await t.test('returns copies: mutating a result does not change the stored config', () => { - const { config } = updateConfig({}) - config.fallbackPlaylist.repeat = true - getConfig().fallbackPlaylist.shuffle = true + await t.test('getConfig() hands out the live object: one snapshot per read, no per-read copy', () => { + assert.strictEqual(getConfig(), getConfig()) - assert.equal(getConfig().fallbackPlaylist.repeat, false) - assert.equal(getConfig().fallbackPlaylist.shuffle, false) + const { config } = updateConfig({}) + assert.strictEqual(config, getConfig()) }) await t.test('an update that changes nothing rewrites no file', async () => { diff --git a/tests/backend/integrations/twitch/close-quietly.test.ts b/tests/backend/integrations/twitch/close-quietly.test.ts index 99cf8ed..84c773c 100644 --- a/tests/backend/integrations/twitch/close-quietly.test.ts +++ b/tests/backend/integrations/twitch/close-quietly.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import WebSocket from 'ws' -import { closeQuietly } from '../../../../src/integrations/twitch/socket.js' +import { closeQuietly, terminateQuietly } from '../../../../src/integrations/twitch/socket.js' test('closeQuietly()', async (t) => { await t.test('closing a socket that is still connecting does not raise an unhandled error', async () => { @@ -27,3 +27,28 @@ test('closeQuietly()', async (t) => { assert.doesNotThrow(() => closeQuietly(undefined)) }) }) + +test('terminateQuietly()', async (t) => { + await t.test('terminating a socket that is still connecting does not raise an unhandled error', async () => { + const uncaught: Error[] = [] + const record = (error: Error) => uncaught.push(error) + process.on('uncaughtException', record) + + try { + terminateQuietly(new WebSocket('ws://127.0.0.1:9')) + await new Promise((resolve) => setTimeout(resolve, 300)) + } finally { + process.off('uncaughtException', record) + } + + assert.deepEqual( + uncaught.map((error) => error.message), + [] + ) + }) + + await t.test('null and undefined are accepted', () => { + assert.doesNotThrow(() => terminateQuietly(null)) + assert.doesNotThrow(() => terminateQuietly(undefined)) + }) +}) diff --git a/tests/backend/integrations/twitch/config.test.ts b/tests/backend/integrations/twitch/config.test.ts index d94cae9..5dec6b3 100644 --- a/tests/backend/integrations/twitch/config.test.ts +++ b/tests/backend/integrations/twitch/config.test.ts @@ -123,6 +123,28 @@ test('validateTwitchConfigUpdates()', async (t) => { }) } + await t.test('refuses a command that collides with an unchanged one, and keeps the stored value', () => { + const { rejected } = updateTwitchConfig({ chatCommands: { skip: { command: '!sg now' } } }) + + assert.deepEqual(rejected, ['chatCommands.skip.command']) + assert.equal(getTwitchConfig().chatCommands.skip.command, DEFAULTS.chatCommands.skip.command) + }) + + await t.test('a mutual exchange of two commands lands in one update', () => { + const { clean, rejected } = validateTwitchConfigUpdates({ + chatCommands: { now: { command: DEFAULTS.chatCommands.skip.command }, skip: { command: DEFAULTS.chatCommands.now.command } } + }) + + assert.deepEqual(rejected, []) + assert.deepEqual([clean.chatCommands?.now?.command, clean.chatCommands?.skip?.command], [DEFAULTS.chatCommands.skip.command, DEFAULTS.chatCommands.now.command]) + }) + + await t.test('two commands moving to the same word are both refused', () => { + const { rejected } = validateTwitchConfigUpdates({ chatCommands: { now: { command: '!sg same' }, skip: { command: '!sg same' } } }) + + assert.deepEqual([...rejected].sort(), ['chatCommands.now.command', 'chatCommands.skip.command']) + }) + await t.test('refuses unknown fields, including prototype keys', () => { const { clean, rejected } = validateTwitchConfigUpdates( JSON.parse('{"foo":1,"__proto__":{"polluted":true},"constructor":1,"channelPointsRewardId":"reward-123"}') @@ -183,14 +205,10 @@ test('updateTwitchConfig()', async (t) => { assert.deepEqual(config.chatCommands, before) }) - await t.test('returns copies: mutating a result does not change the stored config', () => { - const { config } = updateTwitchConfig({}) - const originalChannelPointsRewardId = config.channelPointsRewardId - - // Mutate the returned config (primitive value to test top-level cloning) - config.channelPointsRewardId = 'modified' + await t.test('getConfig() hands out the live object: one snapshot per read, no per-read copy', () => { + assert.strictEqual(getTwitchConfig(), getTwitchConfig()) - // The stored config should have the original value - assert.equal(getTwitchConfig().channelPointsRewardId, originalChannelPointsRewardId) + const { config } = updateTwitchConfig({}) + assert.strictEqual(config, getTwitchConfig()) }) }) diff --git a/tests/backend/integrations/twitch/eventsub.test.ts b/tests/backend/integrations/twitch/eventsub.test.ts index 29fa197..19303b3 100644 --- a/tests/backend/integrations/twitch/eventsub.test.ts +++ b/tests/backend/integrations/twitch/eventsub.test.ts @@ -1,12 +1,18 @@ import { test, after } from 'node:test' import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { AddressInfo } from 'node:net' import { WebSocketServer, type WebSocket as ServerSocket } from 'ws' import { TwitchEventSub } from '../../../../src/integrations/twitch/eventsub.js' import { CHANNEL_POINTS_REDEMPTION, type TwitchClient } from '../../../../src/integrations/twitch/client.js' import type { TwitchChannelPointsRedemption } from '../../../../src/integrations/twitch/types.js' +import { getActivity, clearActivity } from '../../../../src/activity.js' // EventSub, as far as the app is concerned: a socket that says "welcome", then sends notifications. A local server plays Twitch. +// The journal of a lost connection lives in data/activity.json: keep the test away from the real one +process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-eventsub-test-'))) let counter = 0 const meta = (type: string, id = `m-${++counter}`) => ({ message_id: id, message_type: type, message_timestamp: new Date().toISOString() }) @@ -66,7 +72,7 @@ async function startServer({ welcomeMessage = () => welcome() }: ServerOptions = class TestEventSub extends TwitchEventSub { watchdogs: number[] = [] - protected timing = { initialDelayMs: 5, maxDelayMs: 20, maxAttempts: Infinity, readyTimeoutMs: 300, stableAfterMs: 0 } + protected timing = { initialDelayMs: 5, maxDelayMs: 20, readyTimeoutMs: 300, stableAfterMs: 0 } constructor(url: string, client: TwitchClient, onRedemption?: (event: TwitchChannelPointsRedemption) => void) { super({ client, onChannelPointsRedemption: onRedemption }) @@ -143,6 +149,49 @@ test('TwitchEventSub: connecting', async (t) => { }) }) +test('TwitchEventSub: a connection lost to the outside', async (t) => { + await t.test('a blip under the threshold reconnects without a line in the journal', async () => { + const { sub, server } = await setup() + clearActivity() + await sub.connect() + + // the network drops the connection: the socket reconnects by itself (the test timing retries within milliseconds) + server.dropClients() + await until(() => server.connections() >= 2, 'the reconnection') + await until(() => sub.isConnected(), 'the session to be ready again') + + assert.equal(getActivity().some((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST'), false) + }) + + await t.test('an outage long enough to miss redemptions leaves one line in the journal', async () => { + const { sub, server } = await setup() + // the real threshold is a minute (a blip stays out of the journal); here only the arithmetic is tested + ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 0 + clearActivity() + await sub.connect() + + server.dropClients() + await until(() => getActivity().some((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST'), 'the journal line') + + const entry = getActivity().find((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST') + assert.equal(entry?.status, 'failed') + assert.equal(entry?.requestedBy, 'Twitch') + }) + + await t.test('an intentional disconnect journals nothing', async () => { + const { sub } = await setup() + ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 0 + clearActivity() + await sub.connect() + + await sub.disconnect() + await sub.connect() + await until(() => sub.isConnected(), 'the session to be ready again') + + assert.equal(getActivity().some((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST'), false) + }) +}) + test('TwitchEventSub: redemptions', async (t) => { await t.test('a redemption notification reaches the handler', async () => { const { sub, server, events } = await setup() diff --git a/tests/backend/secrets.test.ts b/tests/backend/secrets.test.ts index 54bbcd7..37094dc 100644 --- a/tests/backend/secrets.test.ts +++ b/tests/backend/secrets.test.ts @@ -152,12 +152,8 @@ test('clearTwitchOAuthState()', async (t) => { }) test('getSecrets()', async (t) => { - await t.test('returns a copy: mutating the result does not change the stored secrets', () => { - updateSecrets({ youtubeApiKey: 'original' }) - const secrets = getSecrets() - secrets.youtubeApiKey = 'tampered' - - assert.equal(getSecrets().youtubeApiKey, 'original') + await t.test('hands out the live object: one snapshot per read, no per-read copy', () => { + assert.strictEqual(getSecrets(), getSecrets()) }) }) diff --git a/tests/backend/shutdown.test.ts b/tests/backend/shutdown.test.ts index 1267034..afa9c97 100644 --- a/tests/backend/shutdown.test.ts +++ b/tests/backend/shutdown.test.ts @@ -39,6 +39,8 @@ test('shutdown(): the safety net', async (t) => { const done = fresh(7, (code: number) => void codes.push(code)) context.mock.timers.tick(5000) // the call has only just started: saving cannot have finished yet + context.mock.timers.tick(1000) // the safety net first flushes what is written, bounded by its 1s race + await new Promise((resolve) => setImmediate(resolve)) // the exit is a microtask off the race assert.deepEqual(codes, [7], 'the safety net ended the process') await done diff --git a/tests/backend/state-file.test.ts b/tests/backend/state-file.test.ts index 119793f..8d27114 100644 --- a/tests/backend/state-file.test.ts +++ b/tests/backend/state-file.test.ts @@ -124,7 +124,8 @@ test('sanitizeState()', async (t) => { state.fallback.cursor = 1 const result = sanitizeState(state) - assert.deepEqual(result.fallback?.order, [F1, F2]) + // the saved order is dropped whole: the load rebuilds it from the tracks, the position starts over + assert.deepEqual(result.fallback?.order, []) assert.equal(result.fallback?.cursor, -1) assert.equal( result.problems.some((problem) => problem.includes('rotation is damaged')), diff --git a/tests/backend/twitch-rewards.test.ts b/tests/backend/twitch-rewards.test.ts index 2692ad3..3326210 100644 --- a/tests/backend/twitch-rewards.test.ts +++ b/tests/backend/twitch-rewards.test.ts @@ -23,7 +23,7 @@ test('a new reward', async (t) => { const { rejected } = create({ title: ' ', cost: 0, - prompt: 'x'.repeat(141), + prompt: 'x'.repeat(201), backgroundColor: 'red', isEnabled: 'yes', limits: { maxPerStream: { enabled: true, value: null }, globalCooldownSeconds: { enabled: 'on', value: 5 } } @@ -32,6 +32,14 @@ test('a new reward', async (t) => { assert.deepEqual(rejected, ['title', 'cost', 'prompt', 'backgroundColor', 'isEnabled', 'limits.maxPerStream.value', 'limits.globalCooldownSeconds.enabled']) }) + await t.test('a prompt of the documented maximum length passes', () => { + // the API reference caps the prompt at 200 characters - it was capped at 140 here before + const { payload, rejected } = create({ title: 'a', cost: 1, prompt: 'x'.repeat(200) }) + + assert.deepEqual(rejected, []) + assert.equal(payload.prompt, 'x'.repeat(200)) + }) + await t.test('an empty prompt and an empty colour are nothing at all', () => { assert.deepEqual(create({ title: 'a', cost: 1, prompt: ' ', backgroundColor: '' }).payload, { title: 'a', cost: 1 }) }) diff --git a/tests/backend/youtube/client.test.ts b/tests/backend/youtube/client.test.ts index 3a08f5f..7ae8828 100644 --- a/tests/backend/youtube/client.test.ts +++ b/tests/backend/youtube/client.test.ts @@ -9,7 +9,7 @@ import type { Song, FilterFailureReason, Config } from '../../../src/types.js' // updateConfig() saves to data/config.json under the working directory: use a throwaway one, not the real one process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { isAvailableInRegion, checkFilters, isMusicVideo } = await import('../../../src/youtube/client.js') +const { isAvailableInRegion, checkFilters, isMusicVideo } = await import('../../../src/youtube/filters.js') const { updateConfig, getConfig } = await import('../../../src/config.js') // the reason the filters would answer with, or null when the track passes them all diff --git a/tests/backend/youtube/request.test.ts b/tests/backend/youtube/request.test.ts index 10e9592..34deca0 100644 --- a/tests/backend/youtube/request.test.ts +++ b/tests/backend/youtube/request.test.ts @@ -43,11 +43,12 @@ test('youtube() requests', async (t) => { }) await t.test('a response of an unexpected shape keeps its cause in the thrown message', async () => { - // a 200 whose body is not an object breaks the mapping far from the request: the wrapper must say what broke + // a 200 whose body is not an object is refused by youtube() itself: the caller maps the answer right + // after, and the promise of an AppError must hold all the way to the API error layer stub(json(200, null)) await assert.rejects( getVideoById('aaaaaaaaaaa'), - (error: { code?: string; message?: string }) => error.code === 'YOUTUBE_ERROR' && /YouTube API failed: TypeError:/.test(error.message ?? '') + (error: { code?: string; message?: string }) => error.code === 'YOUTUBE_ERROR' && /malformed/.test(error.message ?? '') ) }) From 5aeb4eb2cfcd24a50a521a1dd64d3aee150e9e7f Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:27:39 +0700 Subject: [PATCH 16/22] Simplify track duration filtering and improve playability checks Track filtering: - remove the Shorts setting and its rejection rule - rely on configured duration limits to determine track eligibility - allow active broadcasts while continuing to reject unfinished uploads and scheduled streams Testing: - add regression tests for track playability and duration filtering - cover fallback playlist loading --- README.md | 1 - public/index.html | 5 -- public/locales/en.json | 2 - public/locales/ru.json | 2 - src/config.ts | 2 - src/http.ts | 1 - src/types.ts | 2 - src/youtube/client.ts | 3 +- src/youtube/filters.ts | 16 +---- tests/backend/fallback.refresh.test.ts | 98 ++++++++++++++++++++++++++ tests/backend/youtube/client.test.ts | 44 ++++++++---- tests/frontend/control/form.test.js | 30 ++++---- 12 files changed, 148 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index ad3266a..25a59ac 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,6 @@ Most configuration is editable from the web control panel. | Max queue size | 20 | | Max active requests per user | 4 | | Region | none | -| Allow Shorts | disabled | | Allow live streams | disabled | | Fallback playlist | disabled until configured | diff --git a/public/index.html b/public/index.html index a7dbad4..bc1d3b8 100644 --- a/public/index.html +++ b/public/index.html @@ -527,11 +527,6 @@ <h2 data-i18n="settings.bot.title">Bot Settings</h2> </div> <div class="settings-section"> - <label class="field-row"> - <span class="field-label" data-i18n="settings.bot.limits.allowShorts">Shorts</span> - <input class="settings-input checkbox" type="checkbox" id="cfgAllowShorts" name="allowShorts" /> - </label> - <label class="field-row"> <span class="field-label" data-i18n="settings.bot.limits.allowLiveStreams">Live streams</span> <input class="settings-input checkbox" type="checkbox" id="cfgAllowLiveStreams" name="allowLiveStreams" /> diff --git a/public/locales/en.json b/public/locales/en.json index ac59336..3eb1d42 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -31,7 +31,6 @@ "REGION_BLOCKED": "@{{user}}, that video is not available in this region, points refunded", "NOT_PLAYABLE": "@{{user}}, that video cannot be played, points refunded", "IS_LIVE": "@{{user}}, live streams cannot be queued, points refunded", - "IS_SHORT": "@{{user}}, shorts cannot be queued, points refunded", "DURATION_OUT_OF_RANGE": "@{{user}}, that track does not meet the length requirement, points refunded", "VIEWS_TOO_LOW": "@{{user}}, that track does not meet the view count requirement, points refunded", "PLAYBACK_VIDEO_UNAVAILABLE": "@{{user}}, “{{title}}” could not be played: the video became unavailable, points refunded", @@ -156,7 +155,6 @@ "maxQueue": "Max queue size", "maxPerUser": "Max requests per user", "regionCode": "Region code", - "allowShorts": "Shorts", "allowLiveStreams": "Live streams", "contentMode": "Content", "contentModeMusic": "Music only", diff --git a/public/locales/ru.json b/public/locales/ru.json index 31b9203..2f64c2d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -31,7 +31,6 @@ "REGION_BLOCKED": "@{{user}}, это видео недоступно в этом регионе, баллы возвращены", "NOT_PLAYABLE": "@{{user}}, это видео нельзя воспроизвести, баллы возвращены", "IS_LIVE": "@{{user}}, трансляции нельзя добавлять в очередь, баллы возвращены", - "IS_SHORT": "@{{user}}, шортсы нельзя добавлять в очередь, баллы возвращены", "DURATION_OUT_OF_RANGE": "@{{user}}, этот трек не соответствует требованиям по длительности, баллы возвращены", "VIEWS_TOO_LOW": "@{{user}}, этот трек не соответствует требованиям по просмотрам, баллы возвращены", "PLAYBACK_VIDEO_UNAVAILABLE": "@{{user}}, «{{title}}» не удалось сыграть: видео стало недоступно, баллы возвращены", @@ -156,7 +155,6 @@ "maxQueue": "Макс. размер очереди", "maxPerUser": "Макс. запросов на пользователя", "regionCode": "Код региона", - "allowShorts": "Видео Shorts", "allowLiveStreams": "Прямая трансляции", "contentMode": "Контент", "contentModeMusic": "Только музыка", diff --git a/src/config.ts b/src/config.ts index 189aac6..67d8c9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,6 @@ export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig, on maxDurationSeconds: 480, maxQueueSize: 20, maxRequestsPerUser: 4, - allowShorts: false, allowLiveStreams: false, contentMode: 'music', regionCode: '', @@ -29,7 +28,6 @@ export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig, on maxDurationSeconds: rules.integer(1, MAX_DURATION_SECONDS), maxQueueSize: rules.integer(1, MAX_COUNT), maxRequestsPerUser: rules.integer(0, MAX_COUNT), - allowShorts: rules.boolean, allowLiveStreams: rules.boolean, contentMode: field({ normalize: (v) => (typeof v === 'string' ? v.trim().toLowerCase() : v), diff --git a/src/http.ts b/src/http.ts index a0f9aba..00c0d2c 100644 --- a/src/http.ts +++ b/src/http.ts @@ -72,7 +72,6 @@ const STATUS_BY_CODE: Record<HttpErrorCode, number> = { 'REGION_BLOCKED', 'NOT_PLAYABLE', 'IS_LIVE', - 'IS_SHORT', 'DURATION_OUT_OF_RANGE', 'VIEWS_TOO_LOW' ], diff --git a/src/types.ts b/src/types.ts index 97cb5e1..2ee2e9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,7 +68,6 @@ export type Config = { maxDurationSeconds: number maxQueueSize: number maxRequestsPerUser: number - allowShorts: boolean allowLiveStreams: boolean contentMode: 'music' | 'any' regionCode: string @@ -127,7 +126,6 @@ export type FilterFailureReason = | 'REGION_BLOCKED' | 'NOT_PLAYABLE' | 'IS_LIVE' - | 'IS_SHORT' | 'DURATION_OUT_OF_RANGE' | 'VIEWS_TOO_LOW' diff --git a/src/youtube/client.ts b/src/youtube/client.ts index 0b61a0d..12063fc 100644 --- a/src/youtube/client.ts +++ b/src/youtube/client.ts @@ -4,7 +4,7 @@ import { AppError } from '../types.js' import { VideoItem } from './types.js' import { isQuotaExhausted, markQuotaExhausted } from './cache.js' import { isPlain } from '../persist.js' -import { VIDEO_DETAILS_PART, isMusicVideo, isAvailableInRegion, isLikelyShort } from './filters.js' +import { VIDEO_DETAILS_PART } from './filters.js' import { fetchWithTimeout, retryableTransport } from '../web-fetch.js' type YouTubeErrorResponse = { @@ -15,7 +15,6 @@ type YouTubeErrorResponse = { } } -const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]) const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 500 diff --git a/src/youtube/filters.ts b/src/youtube/filters.ts index 4a9cc54..62cff43 100644 --- a/src/youtube/filters.ts +++ b/src/youtube/filters.ts @@ -8,10 +8,6 @@ type FilterRule = { params?: (config: Config) => Record<string, string | number> | undefined } -// A duration alone cannot tell a Short from a short song: only the lowest bound is safe to call a Short. -// Anything longer is a regular track; the streamer who wants short content gone raises minDurationSeconds. -const SHORTS_MAX_DURATION_SECONDS = 180 - // The parts the video fetch asks for: what the rules below read (plus what the mapper shows) export const VIDEO_DETAILS_PART = 'snippet,contentDetails,statistics,status,topicDetails' @@ -61,9 +57,11 @@ const isNotPublic = (video: VideoItem): boolean => { return privacy !== undefined && privacy !== 'public' } +// A broadcast answers to the live-stream rules, not to the upload status: while it is on, YouTube reports +// its upload as 'uploaded', and only the recording becomes 'processed' once the stream is over const isNotProcessed = (video: VideoItem): boolean => { const upload = video.status?.uploadStatus - return upload !== undefined && upload !== 'processed' + return upload !== undefined && upload !== 'processed' && !isLiveBroadcast(video) } const FILTER_RULES: FilterRule[] = [ @@ -110,10 +108,6 @@ const FILTER_RULES: FilterRule[] = [ reason: 'DURATION_OUT_OF_RANGE', check: (s, v, c) => !isLiveBroadcast(v) && (s.duration < c.minDurationSeconds || s.duration > c.maxDurationSeconds), params: (c) => ({ min: c.minDurationSeconds, max: c.maxDurationSeconds }) - }, - { - reason: 'IS_SHORT', - check: (s, _v, c) => !c.allowShorts && isLikelyShort(s.duration) } ] @@ -129,10 +123,6 @@ export function isAvailableInRegion(video: VideoItem, regionCode: string): boole return true } -function isLikelyShort(durationSeconds: number): boolean { - return durationSeconds > 0 && durationSeconds <= SHORTS_MAX_DURATION_SECONDS -} - /** * The one filter check: the first rule a track fails becomes the error the API and the chat answer with. * `guardOnly` limits the check to the guard rules: the streamer's own playlist answers to those alone, what diff --git a/tests/backend/fallback.refresh.test.ts b/tests/backend/fallback.refresh.test.ts index 58491e4..ee0e7e6 100644 --- a/tests/backend/fallback.refresh.test.ts +++ b/tests/backend/fallback.refresh.test.ts @@ -375,4 +375,102 @@ test('what loading the playlist keeps and reports', async (t) => { globalThis.fetch = realFetch } }) + + await t.test('a playlists endpoint with nothing to say takes the number away, not the tracks', async (t2) => { + const warnings = t2.mock.method(console, 'warn', () => {}) + const realFetch = globalThis.fetch + let videosReached = 0 + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + + if (url.pathname.endsWith('/playlistItems')) { + return json({ items: [{ snippet: { resourceId: { videoId: A } } }, { snippet: { resourceId: { videoId: B } } }] }) + } + // only the playlists endpoint is down, everything else answers: the size is decoration, it must not + // be able to sink the load the way it used to when it was asked for before the videos + if (url.pathname.endsWith('/playlists')) { + return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 500 }) + } + + videosReached++ + const requestedIds = (url.searchParams.get('id') ?? '').split(',').filter(Boolean) + return json({ + items: requestedIds.map((id) => ({ + id, + snippet: { title: `Title ${id}`, channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: '' } } }, + contentDetails: { duration: 'PT3M20S' }, + statistics: { viewCount: '1000000' }, + status: { privacyStatus: 'public', embeddable: true, uploadStatus: 'processed' } + })) + }) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 2, 'the tracks are loaded anyway') + assert.deepEqual(state.upNext.map((track) => track.videoId), [A, B]) + assert.equal(state.itemCount, null, 'the declared size is simply missing') + assert.equal(videosReached, 1, 'the load reached the videos endpoint') + + assert.equal(warnings.mock.callCount(), 1, 'exactly one warning says why') + const line = String(warnings.mock.calls[0]?.arguments ?? '') + assert.ok(line.includes('declared size'), line) + assert.ok(line.includes('refused'), line) + } finally { + globalThis.fetch = realFetch + } + }) + + await t.test('a playlists endpoint with nothing to say takes the number away, not the tracks', async (t2) => { + const warnings = t2.mock.method(console, 'warn', () => {}) + const realFetch = globalThis.fetch + let videosReached = 0 + globalThis.fetch = async (input, init) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + if (url.hostname !== 'www.googleapis.com') return realFetch(input, init) + + if (url.pathname.endsWith('/playlistItems')) { + return json({ items: [{ snippet: { resourceId: { videoId: A } } }, { snippet: { resourceId: { videoId: B } } }] }) + } + // only the playlists endpoint is down, everything else answers: the size is decoration, it must not + // be able to sink the load the way it used to when it was asked for before the videos + if (url.pathname.endsWith('/playlists')) { + return new Response(JSON.stringify({ error: { message: 'refused' } }), { status: 500 }) + } + + videosReached++ + const requestedIds = (url.searchParams.get('id') ?? '').split(',').filter(Boolean) + return json({ + items: requestedIds.map((id) => ({ + id, + snippet: { title: `Title ${id}`, channelTitle: 'Channel', categoryId: '10', thumbnails: { medium: { url: '' } } }, + contentDetails: { duration: 'PT3M20S' }, + statistics: { viewCount: '1000000' }, + status: { privacyStatus: 'public', embeddable: true, uploadStatus: 'processed' } + })) + }) + } + + try { + updateConfig({ fallbackPlaylist: { playlistId: PL } }) + await fallback.settledConfigReaction(PL) + + const state = fallback.getFallbackState() + assert.equal(state.sourceCount, 2, 'the tracks are loaded anyway') + assert.deepEqual(state.upNext.map((track) => track.videoId), [A, B]) + assert.equal(state.itemCount, null, 'the declared size is simply missing') + assert.equal(videosReached, 1, 'the load reached the videos endpoint') + + assert.equal(warnings.mock.callCount(), 1, 'exactly one warning says why') + const line = String(warnings.mock.calls[0]?.arguments ?? '') + assert.ok(line.includes('declared size'), line) + assert.ok(line.includes('refused'), line) + } finally { + globalThis.fetch = realFetch + } + }) }) diff --git a/tests/backend/youtube/client.test.ts b/tests/backend/youtube/client.test.ts index 7ae8828..da952e8 100644 --- a/tests/backend/youtube/client.test.ts +++ b/tests/backend/youtube/client.test.ts @@ -115,20 +115,21 @@ test('checkFilters', async (t) => { assert.equal(reasonOf(baseSong(), video), 'REGION_BLOCKED') }) - await t.test('a track of up to three minutes is refused as a Short when they are not allowed', () => { - updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 100_000, regionCode: '', allowShorts: false }) - - assert.equal(reasonOf(baseSong({ duration: 120 }), baseVideo()), 'IS_SHORT') - - updateConfig({ allowShorts: true }) - assert.equal(reasonOf(baseSong({ duration: 120 }), baseVideo()), null) - updateConfig({ allowShorts: false }) + await t.test('a short song is no reason on its own: only the configured range has a say about length', () => { + // the maximum must stay within the schema's day-long ceiling, or the field is quietly refused + updateConfig({ minViews: 0, minDurationSeconds: 0, maxDurationSeconds: 3600, regionCode: '' }) + + // the Shorts rule used to refuse any track under the line; 2:30 and 3:00 songs were its victims, + // and a duration alone cannot tell a Short from a short song anyway - so the rule is gone for good + assert.equal(reasonOf(baseSong({ duration: 60 }), baseVideo()), null) + assert.equal(reasonOf(baseSong({ duration: 150 }), baseVideo()), null) + assert.equal(reasonOf(baseSong({ duration: 180 }), baseVideo()), null) }) await t.test('guardOnly: the guard rules alone say whether a track can be played at all', () => { - // the fields the filters never read are filled in with the repo defaults: only the six below matter + // the fields the filters never read are filled in with the repo defaults: only the five below matter const config: Config = { - contentMode: 'music', minViews: 100_000, minDurationSeconds: 60, maxDurationSeconds: 480, allowShorts: false, regionCode: 'RU', + contentMode: 'music', minViews: 100_000, minDurationSeconds: 60, maxDurationSeconds: 480, regionCode: 'RU', maxQueueSize: 20, maxRequestsPerUser: 4, allowLiveStreams: false, fallbackPlaylist: { playlistId: null, enabled: true, shuffle: false, repeat: false } } @@ -172,7 +173,9 @@ test('contentMode', async (t) => { }) test('live streams', async (t) => { - const live = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'live' } }) + // while the stream is on, YouTube reports its upload as 'uploaded': the not-playable guard must not read + // that as an unfinished upload, or no stream the toggle allows would ever pass + const live = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'live' }, status: { embeddable: true, uploadStatus: 'uploaded' } }) const stream = baseSong({ duration: 0 }) const open = { minViews: 0, minDurationSeconds: 60, maxDurationSeconds: 480, regionCode: '' } @@ -190,12 +193,16 @@ test('live streams', async (t) => { test('scheduled streams', async (t) => { const open = { minViews: 0, minDurationSeconds: 60, maxDurationSeconds: 480, regionCode: '' } - const upcoming = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'upcoming' } }) - const live = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'live' } }) + // a scheduled stream reports its upload as 'uploaded' like the one that is on, a premiere as 'processed': + // either way the broadcast has not started, so there is nothing to play yet + const upcoming = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'upcoming' }, status: { embeddable: true, uploadStatus: 'uploaded' } }) + const premiere = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'upcoming' }, status: { embeddable: true, uploadStatus: 'processed' } }) + const live = baseVideo({ snippet: { categoryId: '10', liveBroadcastContent: 'live' }, status: { embeddable: true, uploadStatus: 'uploaded' } }) await t.test('an upcoming stream or premiere is refused even when live streams are allowed: there is nothing to play yet', () => { updateConfig({ ...open, allowLiveStreams: true }) assert.equal(reasonOf(baseSong({ duration: 0 }), upcoming), 'NOT_PLAYABLE') + assert.equal(reasonOf(baseSong({ duration: 0 }), premiere), 'NOT_PLAYABLE') }) await t.test('a stream that is on now is still accepted when live streams are allowed', () => { @@ -205,6 +212,17 @@ test('scheduled streams', async (t) => { }) }) +test('an upload that has not finished processing', async (t) => { + const open = { minViews: 0, minDurationSeconds: 60, maxDurationSeconds: 480, regionCode: '' } + // a plain upload reports 'uploaded' only while it is still processing: there is nothing to play yet + const processing = baseVideo({ snippet: { categoryId: '10' }, status: { embeddable: true, uploadStatus: 'uploaded' } }) + + await t.test('refused even with live streams allowed: the guard still has plain uploads to speak for', () => { + updateConfig({ ...open, allowLiveStreams: true }) + assert.equal(reasonOf(baseSong({ duration: 0 }), processing), 'NOT_PLAYABLE') + }) +}) + test('a rule carries the params its translation needs', async (t) => { await t.test('REGION_BLOCKED carries no params: the region stays private', () => { const config = { regionCode: 'RU' } as Parameters<typeof checkFilters>[2] diff --git a/tests/frontend/control/form.test.js b/tests/frontend/control/form.test.js index 7a61607..eabf24f 100644 --- a/tests/frontend/control/form.test.js +++ b/tests/frontend/control/form.test.js @@ -13,7 +13,7 @@ document.body.insertAdjacentHTML( `<div id="demo"> <input id="views" name="minViews" type="number" /> <input id="region" name="region.code" type="text" /> - <input id="shorts" name="allowShorts" type="checkbox" /> + <input id="live" name="allowLiveStreams" type="checkbox" /> <select id="mode" name="contentMode"><option value="music">music</option><option value="any">any</option></select> <input id="skipCommand" name="chatCommands.skip.command" type="text" /> <input id="loose" type="text" /> @@ -35,14 +35,14 @@ test('readForm', async (t) => { await t.test('builds the body from the names: nested by their dots, typed by the kind of input, text trimmed', () => { input('views').value = '25' input('region').value = ' US ' - input('shorts').checked = true + input('live').checked = true input('mode').value = 'any' input('skipCommand').value = ' !sg skip ' assert.deepEqual(readForm(form), { minViews: 25, region: { code: 'US' }, - allowShorts: true, + allowLiveStreams: true, contentMode: 'any', chatCommands: { skip: { command: '!sg skip' } } }) @@ -62,7 +62,7 @@ test('readForm', async (t) => { }) test('readForm with changedOnly', async (t) => { - const base = { minViews: 10, region: { code: 'DE' }, allowShorts: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } } + const base = { minViews: 10, region: { code: 'DE' }, allowLiveStreams: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } } await t.test('takes only the inputs that differ from what the form was filled with, nested as usual', () => { fillForm(form, base) @@ -94,27 +94,27 @@ test('readForm with changedOnly', async (t) => { }) test('fillForm', async (t) => { - const stored = { minViews: 10, region: { code: 'DE' }, allowShorts: true, contentMode: 'any', chatCommands: { skip: { command: '!sg skip' } } } + const stored = { minViews: 10, region: { code: 'DE' }, allowLiveStreams: true, contentMode: 'any', chatCommands: { skip: { command: '!sg skip' } } } await t.test('puts the stored values into the inputs by name, whatever the kind of input', () => { fillForm(form, stored) assert.equal(input('views').value, '10') assert.equal(input('region').value, 'DE') - assert.equal(input('shorts').checked, true) + assert.equal(input('live').checked, true) assert.equal(input('mode').value, 'any') assert.equal(input('skipCommand').value, '!sg skip') }) await t.test('what was put in is what "changed" is measured against, for every kind of input', () => { - assert.deepEqual(['views', 'region', 'shorts', 'mode', 'skipCommand'].filter((id) => isChanged(input(id))), []) + assert.deepEqual(['views', 'region', 'live', 'mode', 'skipCommand'].filter((id) => isChanged(input(id))), []) input('views').value = '11' input('region').value = 'FR ' - input('shorts').checked = false + input('live').checked = false input('mode').value = 'music' input('skipCommand').value = '!other' - assert.deepEqual(['views', 'region', 'shorts', 'mode', 'skipCommand'].filter((id) => !isChanged(input(id))), []) + assert.deepEqual(['views', 'region', 'live', 'mode', 'skipCommand'].filter((id) => !isChanged(input(id))), []) input('region').value = ' DE ' assert.equal(isChanged(input('region')), false, 'surrounding spaces are not a change: they are trimmed on save') @@ -136,7 +136,7 @@ test('fillForm', async (t) => { }) test('trackForm', async (t) => { - fillForm(form, { minViews: 10, region: { code: 'DE' }, allowShorts: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } }) + fillForm(form, { minViews: 10, region: { code: 'DE' }, allowLiveStreams: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } }) trackForm(form) await t.test('marks an input yellow while it differs, and plain again when it matches, on input and on change', () => { @@ -217,7 +217,7 @@ test('a select', async (t) => { }) test('saveForm', async (t) => { - const base = { minViews: 10, region: { code: 'DE' }, allowShorts: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } } + const base = { minViews: 10, region: { code: 'DE' }, allowLiveStreams: false, contentMode: 'music', chatCommands: { skip: { command: '!sg skip' } } } await t.test('sends the body, shows what the server stored, and marks stored fields green, refused ones red, the rest plain', async () => { fillForm(form, base) @@ -238,7 +238,7 @@ test('saveForm', async (t) => { assert.deepEqual(marks('views'), ['saved']) assert.deepEqual(marks('region'), ['error']) assert.deepEqual(marks('skipCommand'), ['saved']) - assert.deepEqual(marks('shorts'), []) + assert.deepEqual(marks('live'), []) assert.deepEqual([result.saved, result.refused], [2, 1]) assert.deepEqual(result.rejected, ['region.code']) }) @@ -255,19 +255,19 @@ test('saveForm', async (t) => { input('views').value = '25' let sent = null - // another panel changed the region and the shorts since this form was filled + // another panel changed the region and the live-streams switch since this form was filled await saveForm( form, async (body) => { sent = body - return { config: { ...base, minViews: 25, region: { code: 'FR' }, allowShorts: true }, rejected: [] } + return { config: { ...base, minViews: 25, region: { code: 'FR' }, allowLiveStreams: true }, rejected: [] } }, { changedOnly: true } ) assert.deepEqual(sent, { minViews: 25 }, 'the old values of the other fields are not sent back over the new ones') assert.equal(input('region').value, 'FR', 'the form shows what is stored now') - assert.equal(input('shorts').checked, true) + assert.equal(input('live').checked, true) assert.deepEqual([marks('views'), marks('region')], [['saved'], []]) }) From 588269e05211f53eddff815dffda5d365f29e02b Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:18:36 +0700 Subject: [PATCH 17/22] Preserve unsaved settings and improve playlist status feedback Settings: - preserve unsaved values and status marks when settings reload - clear stale status marks when server values change Control panel: - simplify section headers - report when a playlist is paused --- public/css/app.css | 17 +-- public/index.html | 18 +-- public/js/control/form.js | 18 +-- public/js/control/playlists.js | 4 +- public/js/control/settings.js | 7 +- public/locales/en.json | 1 + public/locales/ru.json | 1 + tests/frontend/control/form.test.js | 24 ++++ .../frontend/control/settings-config.test.js | 116 ++++++++++++++++++ 9 files changed, 172 insertions(+), 34 deletions(-) create mode 100644 tests/frontend/control/settings-config.test.js diff --git a/public/css/app.css b/public/css/app.css index 5f925d3..62a6541 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -390,9 +390,18 @@ button:disabled { justify-content: space-between; align-items: center; padding: 0.5rem 1rem; + gap: 0.5rem; background-color: var(--bg-elevated); } +.section-tab-actions { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + margin-left: auto; + gap: 0.5rem; +} + .row-menu { position: relative; flex-shrink: 0; @@ -427,14 +436,6 @@ button:disabled { flex-shrink: 0; } -.section-tab-actions { - display: inline-flex; - flex-wrap: wrap; - justify-content: flex-end; - align-items: center; - gap: 0.5rem; -} - .row-list-wrapper { flex: 1; min-height: 8rem; diff --git a/public/index.html b/public/index.html index bc1d3b8..b8b5928 100644 --- a/public/index.html +++ b/public/index.html @@ -191,7 +191,7 @@ <div class="section-tab section-tab-queue" data-section="queue"> <div class="section-tab-header"> - <h2><span data-i18n="queue.title">Queue</span> <span class="text-sm text-secondary" id="queueCount">0/0</span></h2> + <span class="text-sm text-secondary" id="queueCount">0/0</span> <button type="button" id="clearQueueBtn" @@ -221,10 +221,7 @@ <h2><span data-i18n="queue.title">Queue</span> <span class="text-sm text-seconda <div class="section-tab section-tab-fallback hidden" data-section="playlist"> <div class="section-tab-header"> - <div> - <h2 data-i18n="fallback.title">Playlist</h2> - <div id="fallbackInfo" class="text-sm text-secondary"></div> - </div> + <span id="fallbackInfo" class="text-sm text-secondary"></span> <div class="section-tab-actions"> <button type="button" @@ -309,7 +306,6 @@ <h2 data-i18n="fallback.title">Playlist</h2> <div class="section-tab section-tab-activity hidden" data-section="activity"> <div class="section-tab-header"> - <h2 data-i18n="activity.title">Activity</h2> <div class="section-tab-actions"> <button type="button" @@ -339,9 +335,6 @@ <h2 data-i18n="activity.title">Activity</h2> </div> <div class="section-tab section-tab-blocklist hidden" data-section="blocklist"> - <div class="section-tab-header"> - <h2 data-i18n="blocklist.title">Blocklist</h2> - </div> <div class="row-list-wrapper" id="blocklistListWrapper" data-list="blocklist"> <div class="row-list"></div> <div class="empty" data-i18n="blocklist.empty">No blocked tracks</div> @@ -351,10 +344,6 @@ <h2 data-i18n="blocklist.title">Blocklist</h2> </section> <section class="container page-tab" data-page-tab="playlists"> - <div class="tab-header"> - <h1 data-i18n="playlists.title">Playlists</h1> - </div> - <div class="playlist-add-wrapper panel section-tab flex-1"> <div class="row"> <input @@ -384,9 +373,6 @@ <h1 data-i18n="playlists.title">Playlists</h1> </section> <section class="container page-tab" data-page-tab="settings"> - <div class="tab-header"> - <h1 data-i18n="settings.title">Settings</h1> - </div> <div class="settings-wrapper"> <div id="overlaySettingsPanel" class="section-tab panel"> <div class="section-tab-header"> diff --git a/public/js/control/form.js b/public/js/control/form.js index f291398..390ae26 100644 --- a/public/js/control/form.js +++ b/public/js/control/form.js @@ -48,21 +48,25 @@ export function readForm(form, { changedOnly = false } = {}) { } // `skip`: names to leave as they are (the ones the server refused keep what was typed). A name the stored object has no value for is left alone -export function fillForm(form, config, { skip = [] } = {}) { +// `keepMarks`: for a fill of a form with what it already shows (the echo of a save): a field keeps its mark instead of +// losing it, but only while the fill brings the same value it holds +export function fillForm(form, config, { skip = [], keepMarks = false } = {}) { for (const input of formInputs(form)) { const value = get(config, input.name) if (skip.includes(input.name) || value === undefined) continue + const next = input.type === 'checkbox' ? Boolean(value) : String(value ?? '') + const shown = input.type === 'checkbox' ? input.checked : input.value + if (!(keepMarks && shown === next)) setFieldState(input, null) + if (input.type === 'checkbox') { - input.checked = input.defaultChecked = Boolean(value) + input.checked = input.defaultChecked = next } else if (input.tagName === 'SELECT') { - input.value = String(value ?? '') - for (const option of input.options) option.defaultSelected = option.value === input.value + input.value = next + for (const option of input.options) option.defaultSelected = option.value === next } else { - input.value = input.defaultValue = String(value ?? '') + input.value = input.defaultValue = next } - - setFieldState(input, null) } syncEnables(form) diff --git a/public/js/control/playlists.js b/public/js/control/playlists.js index af4e465..00a590c 100644 --- a/public/js/control/playlists.js +++ b/public/js/control/playlists.js @@ -36,8 +36,10 @@ export function addPlaylist() { export function activatePlaylist(id) { return run('activating playlist', async () => { + const playlist = state.playlists.find(p => p.id === id) + const wasActive = playlist?.isActive await api.activatePlaylist(id) - toastSuccess(t('toast.playlistActivated')) + toastSuccess(wasActive ? t('toast.playlistDeactivated') : t('toast.playlistActivated')) }) } diff --git a/public/js/control/settings.js b/public/js/control/settings.js index 73d3d27..f0ada5d 100644 --- a/public/js/control/settings.js +++ b/public/js/control/settings.js @@ -8,7 +8,7 @@ import { syncPlayer } from './player.js' import { renderQueue } from './queue.js' import { t } from '../i18n.js' import { toastError } from './toast.js' -import { fillForm, saveForm, toastSaveResult } from './form.js' +import { fillForm, saveForm, toastSaveResult, formInputs, isChanged } from './form.js' let qrCodePromise @@ -126,7 +126,10 @@ export function toggleQr() { export function loadConfig() { return run('loading config', async () => { state.config = await api.getConfig() - fillForm(dom.botSettingsPanel, state.config) + const editing = formInputs(dom.botSettingsPanel) + .filter(isChanged) + .map((input) => input.name) + fillForm(dom.botSettingsPanel, state.config, { skip: editing, keepMarks: true }) }) } diff --git a/public/locales/en.json b/public/locales/en.json index 3eb1d42..94198c8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -286,6 +286,7 @@ "removedFromQueue": "Removed from queue", "playlistAdded": "Playlist added", "playlistActivated": "Playlist activated", + "playlistDeactivated": "Playlist paused", "playlistDeleted": "Playlist deleted", "fallbackRefreshed": "Playlist refreshed", "trackBlocked": "Track blocked", diff --git a/public/locales/ru.json b/public/locales/ru.json index 2f64c2d..e072341 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -286,6 +286,7 @@ "removedFromQueue": "Удалено из очереди", "playlistAdded": "Плейлист добавлен", "playlistActivated": "Плейлист активирован", + "playlistDeactivated": "Плейлист на паузе", "playlistDeleted": "Плейлист удалён", "fallbackRefreshed": "Плейлист обновлён", "trackBlocked": "Трек заблокирован", diff --git a/tests/frontend/control/form.test.js b/tests/frontend/control/form.test.js index eabf24f..b2143eb 100644 --- a/tests/frontend/control/form.test.js +++ b/tests/frontend/control/form.test.js @@ -133,6 +133,30 @@ test('fillForm', async (t) => { assert.equal(input('region').value, 'typed') assert.equal(input('skipCommand').value, 'keep me', 'no stored value: left alone') }) + + await t.test('a mark tells about a save: a usual fill clears it, the echo of the save (keepMarks) keeps it while the value is the same', () => { + fillForm(form, stored) + input('views').classList.add('saved') + input('live').classList.add('saved') + + // a usual fill (another reward in the form, a cancel) clears every mark, whatever the values + fillForm(form, stored) + assert.deepEqual(marks('views'), []) + assert.deepEqual(marks('live'), []) + + // the echo of the save: the same values again + input('views').classList.add('saved') + input('live').classList.add('saved') + fillForm(form, stored, { keepMarks: true }) + assert.deepEqual(marks('views'), ['saved']) + assert.deepEqual(marks('live'), ['saved']) + + // another panel has changed the views since: the green would tell about a save that never happened here + fillForm(form, { ...stored, minViews: 11 }, { keepMarks: true }) + assert.deepEqual(marks('views'), []) + assert.deepEqual(marks('live'), ['saved']) + assert.equal(input('views').value, '11') + }) }) test('trackForm', async (t) => { diff --git a/tests/frontend/control/settings-config.test.js b/tests/frontend/control/settings-config.test.js new file mode 100644 index 0000000..aaa9545 --- /dev/null +++ b/tests/frontend/control/settings-config.test.js @@ -0,0 +1,116 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setupPanel } from './panel-env.js' + +const panel = setupPanel() + +const { initI18n } = await import('../../../public/js/i18n.js') +await initI18n('en') +const settings = await import('../../../public/js/control/settings.js') +const { trackForm } = await import('../../../public/js/control/form.js') + +// what the server stores; a save applies to it, as the real one does +let stored = { + minViews: 10000, + minDurationSeconds: 60, + maxDurationSeconds: 480, + maxQueueSize: 20, + maxRequestsPerUser: 4, + allowLiveStreams: false, + contentMode: 'music', + regionCode: '' +} + +panel.route('GET /api/config', () => ({ ...stored })) +panel.route('PUT /api/config', (body) => { + Object.assign(stored, body) + return { config: { ...stored }, rejected: [] } +}) + +// as bindEvents does, so typing marks the field +trackForm(panel.$('#botSettingsPanel')) + +const field = (id) => panel.$(`#${id}`) +const marks = (id) => ['changed', 'saved', 'error'].filter((name) => field(id).classList.contains(name)) +const fire = (id, type) => field(id).dispatchEvent(new window.Event(type, { bubbles: true })) + +test('reading the bot settings', async (t) => { + await t.test('fills the controls from the server', async () => { + await settings.loadConfig() + + assert.equal(field('cfgMinViews').value, '10000') + assert.equal(field('cfgMaxQueue').value, '20') + assert.equal(field('cfgContentMode').value, 'music') + assert.equal(field('cfgRegionCode').value, '') + }) + + await t.test('a field the user is typing keeps its value and its mark: an echo of the config must not wipe their work', async () => { + field('cfgRegionCode').value = 'zz' + fire('cfgRegionCode', 'input') + assert.deepEqual(marks('cfgRegionCode'), ['changed']) + + // the echo of a save: the config is read again, while the field holds what the user typed + await settings.loadConfig() + + assert.equal(field('cfgRegionCode').value, 'zz') + assert.deepEqual(marks('cfgRegionCode'), ['changed']) + }) + + await t.test('a change made elsewhere lands in the form, and takes the mark of the field it replaces', async () => { + stored = { ...stored, maxQueueSize: 30 } + field('cfgMaxQueue').classList.add('saved') + + await settings.loadConfig() + + assert.equal(field('cfgMaxQueue').value, '30') + assert.deepEqual(marks('cfgMaxQueue'), [], 'the green would tell about a save that never happened here') + }) + + await t.test('cancel still puts the stored values back and clears the marks', async () => { + field('cfgRegionCode').value = 'DE' + fire('cfgRegionCode', 'input') + + settings.cancelConfigChanges() + + assert.equal(field('cfgRegionCode').value, '') + assert.deepEqual(marks('cfgRegionCode'), []) + }) +}) + +test('saving the bot settings', async (t) => { + await settings.loadConfig() + + await t.test('the mark of a saved field survives the echo of the save', async () => { + field('cfgMinViews').value = '15000' + + await settings.saveConfigSetting() + + assert.deepEqual(marks('cfgMinViews'), ['saved']) + assert.ok(panel.toasts().includes('Settings saved')) + + // the echo of the save arrives and reads the config: the mark must not vanish at once + await settings.loadConfig() + + assert.equal(field('cfgMinViews').value, '15000') + assert.deepEqual(marks('cfgMinViews'), ['saved']) + }) + + await t.test('a refused field keeps what was typed, through the echo as well', async () => { + panel.route('PUT /api/config', () => [ + 400, + { error: 'raw', code: 'INVALID_CONFIG', params: { fields: 'regionCode' }, data: { config: { ...stored }, rejected: ['regionCode'] } } + ]) + field('cfgRegionCode').value = 'zz' + + await settings.saveConfigSetting() + + assert.equal(field('cfgRegionCode').value, 'zz') + assert.deepEqual(marks('cfgRegionCode'), ['error']) + assert.ok(panel.toasts().includes('Saved 0, not saved 1 (invalid values)')) + + await settings.loadConfig() + + assert.equal(field('cfgRegionCode').value, 'zz') + assert.deepEqual(marks('cfgRegionCode'), ['error']) + }) +}) From 9f2891369bb71619d10d6224327c63d1ffb363c9 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:53:16 +0700 Subject: [PATCH 18/22] Improve remote-device handling and clarify queue status Remote access: - detect remote devices and hide machine-specific settings and Twitch controls Queue: - treat a queue limit of `0` as unlimited Control panel: - clarify queue, activity, and playlist status labels --- public/css/app.css | 13 ++- public/css/components.css | 22 ++++ public/index.html | 61 +++++----- public/js/control/activity.js | 4 + public/js/control/fallback.js | 5 +- public/js/control/queue.js | 7 +- public/js/control/settings.js | 7 +- public/js/control/state.js | 7 ++ public/js/control/twitch/index.js | 4 +- public/js/control/twitch/twitch-connection.js | 5 +- public/js/control/views/primitives.js | 2 +- public/locales/en.json | 5 + public/locales/ru.json | 5 + src/config.ts | 2 +- src/integrations/twitch/error.ts | 2 +- src/queue.ts | 2 +- src/routes/system.ts | 4 +- src/youtube/cache.ts | 2 +- src/youtube/url.ts | 2 +- tests/backend/chat-replies.test.ts | 2 +- tests/backend/routes/settings.test.ts | 4 +- tests/backend/routes/system.test.ts | 6 + tests/frontend/control/activity.test.js | 1 + .../frontend/control/device-locality.test.js | 105 ++++++++++++++++++ .../frontend/control/fallback-actions.test.js | 19 ++++ tests/frontend/control/queue.test.js | 15 ++- 26 files changed, 266 insertions(+), 47 deletions(-) create mode 100644 tests/frontend/control/device-locality.test.js diff --git a/public/css/app.css b/public/css/app.css index 62a6541..4646075 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -392,11 +392,12 @@ button:disabled { padding: 0.5rem 1rem; gap: 0.5rem; background-color: var(--bg-elevated); + flex-wrap: wrap; } .section-tab-actions { display: inline-flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; margin-left: auto; gap: 0.5rem; @@ -561,8 +562,9 @@ button:disabled { .now-playing-wrapper .now-playing-info { display: flex; flex-direction: column; - gap: 0.5rem; + min-width: 0; margin-top: auto; + gap: 0.5rem; } .now-playing-wrapper .now-playing-controls { @@ -656,6 +658,13 @@ button:disabled { gap: 1rem; } +.remote-note { + grid-column: 1 / -1; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + color: var(--text-secondary); +} + .settings-section-column { display: flex; flex-direction: row; diff --git a/public/css/components.css b/public/css/components.css index 40d13f9..182298d 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -14,6 +14,18 @@ body { display: none !important; } +.remote-only { + display: none; +} + +body.not-local .local-only { + display: none; +} + +body.not-local .remote-only { + display: block; +} + .text-primary { color: var(--text-primary); } @@ -92,6 +104,16 @@ body { text-overflow: ellipsis; } +.truncate-3line { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + white-space: wrap; + overflow: hidden; + text-overflow: ellipsis; +} + .box-shadow { box-shadow: rgba(0, 0, 0, 0.4) 0 0.25rem 0.875rem 0; } diff --git a/public/index.html b/public/index.html index b8b5928..a09a1a2 100644 --- a/public/index.html +++ b/public/index.html @@ -84,10 +84,10 @@ <div class="now-playing-top"> <div class="now-playing-empty empty" id="noPlaying" data-i18n="nowPlaying.nothingPlaying">Nothing playing</div> <div class="now-playing-info hidden" id="nowPlaying"> - <div class="text-lg text-bold" id="currentTitle"></div> - <div class="text-secondary text-md" id="currentChannel"></div> + <div class="text-lg text-bold truncate-3line" id="currentTitle"></div> + <div class="text-secondary text-md truncate" id="currentChannel"></div> <div class="text-muted text-sm"><span id="currentDuration"></span> ▪ <span id="currentViews"></span></div> - <div class="text-green text-sm" id="currentRequester"></div> + <div class="text-green text-sm truncate" id="currentRequester"></div> </div> <div class="now-playing-controls"> @@ -191,26 +191,28 @@ <div class="section-tab section-tab-queue" data-section="queue"> <div class="section-tab-header"> - <span class="text-sm text-secondary" id="queueCount">0/0</span> - <button - type="button" - id="clearQueueBtn" - class="btn btn-secondary btn-danger" - data-action="clear-queue" - data-confirm="queue.clearConfirm" - data-i18n-title="queue.clear" - > - <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"> - <path - stroke="currentColor" - stroke-linecap="round" - stroke-linejoin="round" - stroke-width="2" - d="m18 6-.8 12.013c-.071 1.052-.106 1.578-.333 1.977a2 2 0 0 1-.866.81c-.413.2-.94.2-1.995.2H9.994c-1.055 0-1.582 0-1.995-.2a2 2 0 0 1-.866-.81c-.227-.399-.262-.925-.332-1.977L6 6M4 6h16m-4 0-.27-.812c-.263-.787-.394-1.18-.637-1.471a2 2 0 0 0-.803-.578C13.939 3 13.524 3 12.695 3h-1.388c-.829 0-1.244 0-1.596.139a2 2 0 0 0-.803.578c-.243.29-.374.684-.636 1.471L8 6m6 4v7m-4-7v7" - /> - </svg> - <span data-i18n="queue.clear">Clear</span> - </button> + <span class="text-sm text-secondary" id="queueCount">Queue is empty</span> + <div class="section-tab-actions"> + <button + type="button" + id="clearQueueBtn" + class="btn btn-secondary btn-danger" + data-action="clear-queue" + data-confirm="queue.clearConfirm" + data-i18n-title="queue.clear" + > + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24"> + <path + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="m18 6-.8 12.013c-.071 1.052-.106 1.578-.333 1.977a2 2 0 0 1-.866.81c-.413.2-.94.2-1.995.2H9.994c-1.055 0-1.582 0-1.995-.2a2 2 0 0 1-.866-.81c-.227-.399-.262-.925-.332-1.977L6 6M4 6h16m-4 0-.27-.812c-.263-.787-.394-1.18-.637-1.471a2 2 0 0 0-.803-.578C13.939 3 13.524 3 12.695 3h-1.388c-.829 0-1.244 0-1.596.139a2 2 0 0 0-.803.578c-.243.29-.374.684-.636 1.471L8 6m6 4v7m-4-7v7" + /> + </svg> + <span data-i18n="queue.clear">Clear</span> + </button> + </div> </div> <div class="row-list-wrapper" id="queueListWrapper" data-list="queue"> @@ -306,6 +308,7 @@ <div class="section-tab section-tab-activity hidden" data-section="activity"> <div class="section-tab-header"> + <span id="activityCount" class="text-sm text-secondary"></span> <div class="section-tab-actions"> <button type="button" @@ -374,6 +377,10 @@ <section class="container page-tab" data-page-tab="settings"> <div class="settings-wrapper"> + <div id="remoteSettingsNote" class="remote-only remote-note text-sm" data-i18n="settings.remoteNote"> + Opened from another device - settings that only this machine may change (the API key and the Twitch integration) are hidden. Change them + on the computer running the app + </div> <div id="overlaySettingsPanel" class="section-tab panel"> <div class="section-tab-header"> <h2 data-i18n="settings.overlay.title">Overlay</h2> @@ -473,7 +480,7 @@ <h2 data-i18n="settings.overlay.title">Overlay</h2> <h2 data-i18n="settings.bot.title">Bot Settings</h2> </div> - <div class="settings-section"> + <div class="settings-section local-only"> <label class="field"> <span class="field-label" data-i18n="settings.bot.apiKey">API Key</span> <input @@ -504,7 +511,7 @@ <h2 data-i18n="settings.bot.title">Bot Settings</h2> <label class="field"> <span class="field-label" data-i18n="settings.bot.limits.maxQueue">Max queue size</span> - <input class="settings-input input" type="number" id="cfgMaxQueue" name="maxQueueSize" min="1" /> + <input class="settings-input input" type="number" id="cfgMaxQueue" name="maxQueueSize" min="0" /> </label> <label class="field"> <span class="field-label" data-i18n="settings.bot.limits.maxPerUser">Max requests per user</span> @@ -553,7 +560,7 @@ <h2 data-i18n="settings.bot.title">Bot Settings</h2> </div> </div> - <div class="section-tab panel"> + <div class="section-tab panel local-only"> <div class="section-tab-header"> <h2 data-i18n="settings.twitch.integration">Twitch integration</h2> </div> @@ -725,7 +732,7 @@ <h2 data-i18n="settings.twitch.editReward">Edit reward</h2> </div> </div> - <div id="twitchChatCommandsPanel" class="section-tab panel hidden"> + <div id="twitchChatCommandsPanel" class="section-tab panel local-only hidden"> <div class="section-tab-header"> <h2 data-i18n="settings.twitchChat.title">Twitch chat commands</h2> </div> diff --git a/public/js/control/activity.js b/public/js/control/activity.js index 38541af..20343cc 100644 --- a/public/js/control/activity.js +++ b/public/js/control/activity.js @@ -1,13 +1,17 @@ import { api } from './api.js' import { state } from './state.js' +import { dom } from './dom.js' import { renderStats } from './stats.js' import { views } from './views/index.js' import { run } from './run.js' +import { t } from '../i18n.js' +import { setText } from '../shared.js' function renderActivity() { const filter = state.activityFilter const entries = filter === 'all' ? state.activity : state.activity.filter((entry) => entry.status === filter) views.activity.render(entries) + setText(dom.activityCount, t('activity.requests', { count: entries.length })) } export function loadActivity(silent = false) { diff --git a/public/js/control/fallback.js b/public/js/control/fallback.js index 39c506a..6744667 100644 --- a/public/js/control/fallback.js +++ b/public/js/control/fallback.js @@ -29,7 +29,8 @@ export function renderFallback() { setText(dom.fallbackEnabledText, data?.enabled ? t('common.on') : t('common.off')) if (!tracks.length) { - setText(dom.fallbackInfo, '') + // an active playlist that has simply run out still counts as set; only a section with no playlist at all gets the hint + setText(dom.fallbackInfo, data?.playlistId ? '' : t('fallback.notSet')) views.fallback.render([]) renderStats() return @@ -39,7 +40,7 @@ export function renderFallback() { setText( dom.fallbackInfo, - itemCount && itemCount > 0 + itemCount && itemCount > 0 && tracks.length !== itemCount ? t('fallback.infoOf', { count: tracks.length, total: itemCount, diff --git a/public/js/control/queue.js b/public/js/control/queue.js index 2ed90d6..5b77350 100644 --- a/public/js/control/queue.js +++ b/public/js/control/queue.js @@ -41,9 +41,14 @@ export function renderState() { syncPlayer() } +function queueHeaderText(length, max) { + if (max > 0) return t('queue.infoMax', { count: length, max }) + return length > 0 ? t('queue.info', { count: length }) : t('queue.empty') +} + export function renderQueue() { const maxQueueSize = state.config?.maxQueueSize ?? 0 - setText(dom.queueCount, `${state.queue.length}/${maxQueueSize}`) + setText(dom.queueCount, queueHeaderText(state.queue.length, maxQueueSize)) setText(dom.tabQueueCount, state.queue.length) views.queue.render(state.queue) diff --git a/public/js/control/settings.js b/public/js/control/settings.js index f0ada5d..56c5791 100644 --- a/public/js/control/settings.js +++ b/public/js/control/settings.js @@ -1,6 +1,6 @@ import { escapeHtml, show, setValue } from '../shared.js' import { api } from './api.js' -import { state } from './state.js' +import { state, isLocalDevice } from './state.js' import { dom } from './dom.js' import { log } from './log.js' import { run } from './run.js' @@ -76,6 +76,7 @@ export function copyOverlayUrl() { export function loadNetworkInfo() { return run('loading network info', async () => { state.network = await api.getNetworkInfo() + applyDeviceLocality() const { ips } = state.network if (!ips.length) state.selectedIp = 'localhost' @@ -86,6 +87,10 @@ export function loadNetworkInfo() { }) } +export function applyDeviceLocality() { + document.body.classList.toggle('not-local', !isLocalDevice()) +} + function renderIpSelect() { const { ips } = state.network show(dom.selectIp, ips.length > 1) diff --git a/public/js/control/state.js b/public/js/control/state.js index da909f0..428e3a4 100644 --- a/public/js/control/state.js +++ b/public/js/control/state.js @@ -34,3 +34,10 @@ export const state = { editingReward: null } } + +// Whether this connection comes from the machine the app runs on. The server refuses its writes from anywhere else, +// so the panel hides them there. Unknown (the answer never arrived) counts as local: hiding working settings is worse +// than showing refused ones. +export function isLocalDevice() { + return state.network?.isLocal !== false +} diff --git a/public/js/control/twitch/index.js b/public/js/control/twitch/index.js index 5cbb07a..7189005 100644 --- a/public/js/control/twitch/index.js +++ b/public/js/control/twitch/index.js @@ -1,6 +1,6 @@ import { api } from '../api.js' import { run } from '../run.js' -import { state } from '../state.js' +import { state, isLocalDevice } from '../state.js' import { applyChatCommands, bindChatCommandTracking, cancelChatCommandChanges, saveTwitchChatCommands } from './twitch-chat-commands.js' import { connectTwitch, disconnectTwitch } from './twitch-connection.js' import { applyRewardConfig, bindTwitchRewardTracking, cancelRewardChanges, saveReward } from './twitch-rewards.js' @@ -16,6 +16,8 @@ export function bindTwitchFieldTracking() { export function loadTwitchConfig() { return run('loading Twitch config', async () => { + if (!isLocalDevice()) return + const config = await api.getTwitchConfig() state.twitch.config = config diff --git a/public/js/control/twitch/twitch-connection.js b/public/js/control/twitch/twitch-connection.js index 56799ad..0142a4d 100644 --- a/public/js/control/twitch/twitch-connection.js +++ b/public/js/control/twitch/twitch-connection.js @@ -3,7 +3,7 @@ import { t } from '../../i18n.js' import { api, ApiError } from '../api.js' import { run } from '../run.js' import { toastError, toastInfo } from '../toast.js' -import { state } from '../state.js' +import { state, isLocalDevice } from '../state.js' import { dom } from '../dom.js' import { log } from '../log.js' import { loadTwitchRewards, renderTwitchRewards } from './twitch-rewards.js' @@ -82,6 +82,7 @@ export function refreshTwitchStatus() { 'refreshing Twitch status', async () => { if (!state.twitch.configured) return + if (!isLocalDevice()) return const wasConnected = Boolean(state.twitch.connected && state.twitch.user) const waitingFor = state.twitch.authorization @@ -161,6 +162,8 @@ export function loadTwitchSettings() { return } + if (!isLocalDevice()) return + applyStatus(await api.getTwitchStatus()) renderTwitchConnection() diff --git a/public/js/control/views/primitives.js b/public/js/control/views/primitives.js index df4fdc5..5bbc4fb 100644 --- a/public/js/control/views/primitives.js +++ b/public/js/control/views/primitives.js @@ -4,7 +4,7 @@ import { t } from '../../i18n.js' export function createListView(wrapper, { renderRow, getKey, cache = true }) { if (cache && !getKey) { - console.warn('[UI] createListView: no getKey provided — falling back to a naive key that may not detect all changes') + console.warn('[UI] createListView: no getKey provided - falling back to a naive key that may not detect all changes') } const list = wrapper.querySelector('.row-list') diff --git a/public/locales/en.json b/public/locales/en.json index 94198c8..cd16182 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -73,6 +73,8 @@ "queue": { "title": "Queue", "empty": "Queue is empty", + "info": "In queue: {{count}}", + "infoMax": "In queue: {{count}} ▪ limit {{max}}", "remove": "Remove from queue", "clear": "Clear", "clearConfirm": "Clear entire queue?" @@ -80,6 +82,7 @@ "fallback": { "title": "Playlist", "empty": "Playlist is empty", + "notSet": "No active playlist - add one on the Playlists page", "refresh": "Refresh", "shuffle": "Shuffle", "repeat": "Repeat", @@ -94,6 +97,7 @@ "activity": { "title": "Activity", "empty": "No requests yet", + "requests": "Requests: {{count}}", "clear": "Clear", "clearConfirm": "Clear Activity?", "accepted": "accepted", @@ -168,6 +172,7 @@ "description": "Local music request manager for streamers", "developedBy": "Developed by" }, + "remoteNote": "Opened from another device - settings that only this machine may change (the API key and the Twitch integration) are hidden. Change them on the computer running the app", "twitch": { "integration": "Twitch integration", "chanel": "Twitch channel", diff --git a/public/locales/ru.json b/public/locales/ru.json index e072341..89ffa42 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -73,6 +73,8 @@ "queue": { "title": "Очередь", "empty": "Очередь пуста", + "info": "В очереди: {{count}}", + "infoMax": "В очереди: {{count}} ▪ лимит {{max}}", "remove": "Удалить из очереди", "clear": "Очистить", "clearConfirm": "Очистить всю очередь?" @@ -80,6 +82,7 @@ "fallback": { "title": "Плейлист", "empty": "Плейлист пуст", + "notSet": "Нет активного плейлиста - добавьте его на странице «Плейлисты»", "refresh": "Обновить", "shuffle": "Перемешать", "repeat": "Повтор", @@ -94,6 +97,7 @@ "activity": { "title": "Активность", "empty": "Нет запросов", + "requests": "Запросов: {{count}}", "clear": "Очистить", "clearConfirm": "Очистить активность?", "accepted": "Принято", @@ -168,6 +172,7 @@ "description": "Локальное управление музыкальными запросами для стримеров", "developedBy": "Разработано" }, + "remoteNote": "Панель открыта с другого устройства - настройки, доступные только самому компьютеру (ключ API и интеграция с Twitch), скрыты. Меняйте их на компьютере, где запущено приложение", "twitch": { "integration": "Интеграция с Twitch", "chanel": "Twitch канал", diff --git a/src/config.ts b/src/config.ts index 67d8c9e..4a563f7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,7 +26,7 @@ export const { getConfig, updateConfig, validateConfigUpdates, restoreConfig, on minViews: rules.integer(0, MAX_MIN_VIEWS), minDurationSeconds: rules.integer(0, MAX_DURATION_SECONDS), maxDurationSeconds: rules.integer(1, MAX_DURATION_SECONDS), - maxQueueSize: rules.integer(1, MAX_COUNT), + maxQueueSize: rules.integer(0, MAX_COUNT), maxRequestsPerUser: rules.integer(0, MAX_COUNT), allowLiveStreams: rules.boolean, contentMode: field({ diff --git a/src/integrations/twitch/error.ts b/src/integrations/twitch/error.ts index 939296a..9355a6e 100644 --- a/src/integrations/twitch/error.ts +++ b/src/integrations/twitch/error.ts @@ -10,7 +10,7 @@ export function markTransient(error: AppError): AppError { // fetch fails as a TypeError ("fetch failed", "terminated"), and a timeout as a DOMException named // TimeoutError or AbortError: those are "no connection to Twitch". Any other Error is something else (a -// refusal, a bug of our own — a TypeError with another message included) and must not be reported as a +// refusal, a bug of our own - a TypeError with another message included) and must not be reported as a // missing connection const NETWORK_TYPE_ERRORS = new Set(['fetch failed', 'terminated']) diff --git a/src/queue.ts b/src/queue.ts index a527537..40ca444 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -132,7 +132,7 @@ export function assertCanRequestSong(requestedBy: string, { bypassFilters = fals // blocked tracks do not occupy places: they are closed when their turn comes const playable = queue.filter((item) => !isBlocked(item.videoId)).length - if (currentSong !== null && playable >= config.maxQueueSize) { + if (currentSong !== null && config.maxQueueSize !== 0 && playable >= config.maxQueueSize) { throw new AppError('QUEUE_FULL') } diff --git a/src/routes/system.ts b/src/routes/system.ts index f70edd0..0f6c267 100644 --- a/src/routes/system.ts +++ b/src/routes/system.ts @@ -3,7 +3,7 @@ import { StateResponse, OverlayStateResponse } from '../types.js' import { ok } from '../http.js' import { getState } from '../player.js' import { getSettings } from '../settings.js' -import { localOnly } from '../local-only.js' +import { isLocal, localOnly } from '../local-only.js' import { lanAddresses } from '../network.js' import { shutdown } from '../shutdown.js' @@ -19,7 +19,7 @@ export function createSystemRouter({ exit = (code: number) => process.exit(code) }) router.get('/network-info', (req, res) => { - ok(res, { port: req.socket.localPort, ips: lanAddresses() }) + ok(res, { port: req.socket.localPort, ips: lanAddresses(), isLocal: isLocal(req) }) }) router.post('/shutdown', localOnly, (_req, res) => { diff --git a/src/youtube/cache.ts b/src/youtube/cache.ts index 8ccbe65..29cf23d 100644 --- a/src/youtube/cache.ts +++ b/src/youtube/cache.ts @@ -17,7 +17,7 @@ const videos = new Map<string, Entry<VideoItem | null>>() const log = createLogger('YOUTUBE QUOTA') -// YouTube resets daily API quota at midnight Pacific Time — do not change this timezone. +// YouTube resets daily API quota at midnight Pacific Time - do not change this timezone. // One formatter for the whole run: building it per call is measurable work on every request while the quota is exhausted const quotaDayFormat = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Los_Angeles', diff --git a/src/youtube/url.ts b/src/youtube/url.ts index 402f02b..bb6e179 100644 --- a/src/youtube/url.ts +++ b/src/youtube/url.ts @@ -37,7 +37,7 @@ export function parseYouTubeUrl(input: string): { isYouTube: boolean; videoId: s const url = youtubeUrl(input) if (!url) return { isYouTube: false, videoId: null } - // youtu.be/<id> · /watch?v=<id> · /shorts|embed|v|live/<id> — segment must be the whole id + // youtu.be/<id> · /watch?v=<id> · /shorts|embed|v|live/<id> - segment must be the whole id const [kind, segment] = url.pathname.split('/').filter(Boolean) const id = hostOf(url) === 'youtu.be' ? kind : kind === 'watch' ? url.searchParams.get('v') : VIDEO_KINDS.has(kind) ? segment : null diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 4021bfb..8383af0 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -81,7 +81,7 @@ test('buildRedemptionAcceptedMessage', async (t) => { }) test('buildRedemptionRejectionMessage', async (t) => { - // the reason is a plain { code, params } — a thrown AppError fits the same shape when one reaches this far + // the reason is a plain { code, params } - a thrown AppError fits the same shape when one reaches this far await t.test('a request that was never queued gets only the reason', () => { assert.equal(buildRedemptionRejectionMessage('alice', { code: 'DUPLICATE' }, 'en'), '@alice, that track is already in the queue, points refunded') }) diff --git a/tests/backend/routes/settings.test.ts b/tests/backend/routes/settings.test.ts index 7727a01..bd04672 100644 --- a/tests/backend/routes/settings.test.ts +++ b/tests/backend/routes/settings.test.ts @@ -61,7 +61,7 @@ test('PUT /api/config', async (t) => { const response = await putConfig({ minViews: before.minViews + 1, - maxQueueSize: 0 + maxQueueSize: -1 }) const config = await getConfig() @@ -127,7 +127,7 @@ test('PUT /api/config and the fallback playlist', async (t) => { test('PUT /api/config with a refused field (partial save)', async (t) => { await t.test('stores the valid fields and returns them together with the refused ones', async () => { - const response = await putConfig({ minViews: 4242, maxQueueSize: 0 }) + const response = await putConfig({ minViews: 4242, maxQueueSize: -1 }) const body = (await response.json()) as Record<string, any> assert.equal(response.status, 400) diff --git a/tests/backend/routes/system.test.ts b/tests/backend/routes/system.test.ts index a5918d2..4bc71f6 100644 --- a/tests/backend/routes/system.test.ts +++ b/tests/backend/routes/system.test.ts @@ -59,6 +59,12 @@ test('system routes', async (t) => { assert.ok(Array.isArray(body.data.ips) && body.data.ips.every((ip: unknown) => typeof ip === 'string')) }) + await t.test('GET /network-info says whether this connection comes from the machine itself, so the panel can hide what other devices may not change', async () => { + const body = (await (await api('/network-info')).json()) as Record<string, any> + + assert.equal(body.data.isLocal, true, 'the test asks from 127.0.0.1, which is the machine itself') + }) + await t.test('POST /shutdown from this machine answers, flushes and then exits (once)', async () => { const response = await api('/shutdown', { method: 'POST' }) diff --git a/tests/frontend/control/activity.test.js b/tests/frontend/control/activity.test.js index 5b034e8..ba6fcbb 100644 --- a/tests/frontend/control/activity.test.js +++ b/tests/frontend/control/activity.test.js @@ -21,6 +21,7 @@ test('loadActivity()', async (t) => { assert.equal(state.activity.length, 2) assert.equal(panel.$('#tabActivityCount').textContent, '2') + assert.equal(panel.$('#activityCount').textContent, 'Requests: 2') assert.equal(panel.$$('#activityListWrapper .row-item').length, 2) assert.equal(panel.toasts().join('|'), before) }) diff --git a/tests/frontend/control/device-locality.test.js b/tests/frontend/control/device-locality.test.js new file mode 100644 index 0000000..7c35259 --- /dev/null +++ b/tests/frontend/control/device-locality.test.js @@ -0,0 +1,105 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setupPanel } from './panel-env.js' + +const panel = setupPanel() + +const { initI18n } = await import('../../../public/js/i18n.js') +await initI18n('en') +const { state, isLocalDevice } = await import('../../../public/js/control/state.js') +const { loadNetworkInfo } = await import('../../../public/js/control/settings.js') +const { loadTwitchSettings, refreshTwitchStatus } = await import('../../../public/js/control/twitch/twitch-connection.js') +const { loadTwitchConfig } = await import('../../../public/js/control/twitch/index.js') + +const networkInfo = (isLocal) => ({ port: 4747, ips: isLocal ? ['localhost'] : ['192.168.1.5'], isLocal }) + +const connected = { connected: true, user: { displayName: 'Streamer', login: 'streamer', profileImageUrl: '' }, health: null } + +test('the page knows which blocks another device may not change', async (t) => { + await t.test('the machine-only blocks are marked in the page: the API key, the Twitch section, the chat commands', () => { + assert.ok(panel.$('#secYoutubeKey')?.closest('.local-only'), 'the API key field is inside a machine-only block') + assert.ok(panel.$('#twitchChatCommandsPanel')?.classList.contains('local-only')) + assert.ok(panel.$('[data-i18n="settings.twitch.integration"]')?.closest('.local-only'), 'the Twitch section is machine-only') + }) + + await t.test('the note that replaces them is marked to be shown to another device only', () => { + assert.ok(panel.$('#remoteSettingsNote')?.classList.contains('remote-only')) + assert.equal(panel.$('#remoteSettingsNote').getAttribute('data-i18n'), 'settings.remoteNote') + }) +}) + +test('loadNetworkInfo()', async (t) => { + await t.test('an answer from this machine leaves the page as it is', async () => { + panel.route('GET /api/network-info', networkInfo(true)) + + await loadNetworkInfo() + + assert.equal(isLocalDevice(), true) + assert.equal(document.body.classList.contains('not-local'), false) + }) + + await t.test('an answer from another device marks the page: the CSS hides the machine-only blocks and shows the note', async () => { + panel.route('GET /api/network-info', networkInfo(false)) + + await loadNetworkInfo() + + assert.equal(isLocalDevice(), false) + assert.equal(document.body.classList.contains('not-local'), true) + }) + + await t.test('a page that has not asked yet counts as this machine: hiding working settings is worse than showing refused ones', () => { + state.network = null + + assert.equal(isLocalDevice(), true) + }) +}) + +test('another device reads nothing from the Twitch section', async (t) => { + // the server refuses the whole section with LOCAL_ONLY, so the panel does not even ask + panel.route('GET /api/network-info', networkInfo(false)) + await loadNetworkInfo() + + state.twitch.configured = true + Object.assign(state.twitch, { connected: true, user: connected.user }) + panel.route('GET /api/integrations/twitch', connected) + panel.route('GET /api/integrations/twitch/config', { channelPointsRewardId: '' }) + panel.route('GET /api/integrations/twitch/rewards', { rewards: [] }) + panel.calls.length = 0 + + await t.test('the loaders the panel runs at its start ask nothing', async () => { + const before = panel.toasts().join('|') + + await loadTwitchSettings() + await loadTwitchConfig() + await refreshTwitchStatus() + + assert.deepEqual(panel.calls, [], 'no request may leave: each one would only come back refused') + assert.equal(panel.toasts().join('|'), before, 'a refusal toast on every load is what made the settings look broken') + }) + + await t.test('the same loads on this machine still read as they did', async () => { + panel.route('GET /api/network-info', networkInfo(true)) + await loadNetworkInfo() + panel.calls.length = 0 + + await loadTwitchSettings() + await loadTwitchConfig() + await refreshTwitchStatus() + + assert.deepEqual(panel.calls, [ + 'GET /api/integrations/twitch', + 'GET /api/integrations/twitch/rewards', + 'GET /api/integrations/twitch/config', + 'GET /api/integrations/twitch' + ]) + }) + + await t.test('without an answer yet the loads still run, as they did before', async () => { + state.network = null + panel.calls.length = 0 + + await loadTwitchSettings() + + assert.deepEqual(panel.calls, ['GET /api/integrations/twitch', 'GET /api/integrations/twitch/rewards']) + }) +}) diff --git a/tests/frontend/control/fallback-actions.test.js b/tests/frontend/control/fallback-actions.test.js index dddc814..dfe90ea 100644 --- a/tests/frontend/control/fallback-actions.test.js +++ b/tests/frontend/control/fallback-actions.test.js @@ -8,6 +8,7 @@ const { initI18n } = await import('../../../public/js/i18n.js') await initI18n('en') const { state } = await import('../../../public/js/control/state.js') const actions = await import('../../../public/js/control/fallback-actions.js') +const { renderFallback } = await import('../../../public/js/control/fallback.js') const fallback = (extra = {}) => ({ upNext: [track('f'), track('g')], activeVideoId: null, lastRefreshedAt: 1, sourceCount: 2, shuffle: false, repeat: false, enabled: false, ...extra }) @@ -97,3 +98,21 @@ test('a track of the playlist', async (t) => { assert.equal(typeof actions.fallbackActions['fallback-enqueue'], 'function') }) }) + +test('the header line of the playlist section', async (t) => { + await t.test('a section with no playlist at all explains where to set one', () => { + state.fallback = fallback({ playlistId: null, upNext: [] }) + + renderFallback() + + assert.equal(panel.$('#fallbackInfo').textContent, 'No active playlist - add one on the Playlists page') + }) + + await t.test('a playlist that has simply run out still counts as set: no hint, the line stays for the state of the buttons', () => { + state.fallback = fallback({ playlistId: 'PL123', upNext: [] }) + + renderFallback() + + assert.equal(panel.$('#fallbackInfo').textContent, '') + }) +}) diff --git a/tests/frontend/control/queue.test.js b/tests/frontend/control/queue.test.js index 259bfa5..9fabf3c 100644 --- a/tests/frontend/control/queue.test.js +++ b/tests/frontend/control/queue.test.js @@ -58,7 +58,7 @@ test('renderQueue', async (t) => { queue.renderQueue() - assert.equal(panel.$('#queueCount').textContent, '2/5') + assert.equal(panel.$('#queueCount').textContent, 'In queue: 2 ▪ limit 5') assert.equal(panel.$('#tabQueueCount').textContent, '2') const rows = panel.$$('#queueListWrapper .row-item') @@ -67,6 +67,19 @@ test('renderQueue', async (t) => { assert.ok(rows[1].querySelector('.status-pill')) }) + await t.test('an empty queue says so in words: a bare 0/0 tells nobody anything', () => { + state.config = { maxQueueSize: 5 } + state.queue = [] + + queue.renderQueue() + + assert.equal(panel.$('#queueCount').textContent, 'In queue: 0 ▪ limit 5') + + state.config = { maxQueueSize: 0 } + queue.renderQueue() + assert.equal(panel.$('#queueCount').textContent, 'Queue is empty') + }) + await t.test('a playlist track goes by the localized name, and follows the panel\'s language', async () => { state.config = { maxQueueSize: 5 } state.queue = [track('a', { isFallback: true, requestedBy: 'playlist' })] From 69361e184a48f06c9238555d8baca2cf309a0897 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:53:58 +0700 Subject: [PATCH 19/22] Improve server initialization and harden error handling Server: - move app construction and free-port listening into an import-safe module Twitch: - record outages when they exceed the threshold - cap reward inputs Reliability: - throttle failed log rotations - cancel response bodies before retrying --- src/app.ts | 44 +++++++++ src/integrations/twitch/eventsub.ts | 57 +++++++---- src/integrations/twitch/rewards.ts | 18 ++-- src/logger.ts | 28 ++++-- src/server.ts | 44 +-------- src/validate.ts | 11 ++- src/web-fetch.ts | 2 + tests/backend/app.test.ts | 31 ++++++ .../integrations/twitch/eventsub.test.ts | 97 ++++++++++++++++++- tests/backend/twitch-rewards.test.ts | 23 +++++ tests/backend/web-fetch.test.ts | 49 ++++++++++ 11 files changed, 330 insertions(+), 74 deletions(-) create mode 100644 src/app.ts create mode 100644 tests/backend/app.test.ts create mode 100644 tests/backend/web-fetch.test.ts diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..117a839 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,44 @@ +import express from 'express' +import path from 'node:path' +import type { Server } from 'node:http' + +import { getAppRoot } from './runtime.js' +import { hostCheck } from './host-check.js' +import { determineLocality } from './local-only.js' +import { errorHandler } from './error-handler.js' +import { apiRouter } from './routes/index.js' + +/** The assembled app: nothing runs on import, so a test can build the app without starting it. */ +export function createApp(): express.Express { + const app = express() + const publicDir = path.join(getAppRoot(), 'public') + + app.use(hostCheck) + app.use(determineLocality) + app.use(express.json({ limit: '50kb' })) + app.use(express.static(publicDir, { extensions: ['html'] })) + + app.use('/api', apiRouter) + app.use(errorHandler) + return app +} + +/** Binds the given app to the first free port from `start` upward, so a busy port only shifts the link. */ +export async function listenOnFreePort( + app: express.Express, + start: number +): Promise<{ server: Server; port: number }> { + for (let port = start; port < start + 100; port++) { + try { + const server = await new Promise<Server>((resolve, reject) => { + const candidate = app.listen(port) + candidate.once('listening', () => resolve(candidate)) + candidate.once('error', reject) + }) + return { server, port } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error + } + } + throw new Error(`No free port in ${start}-${start + 99}`) +} diff --git a/src/integrations/twitch/eventsub.ts b/src/integrations/twitch/eventsub.ts index 078a5e3..a3c1216 100644 --- a/src/integrations/twitch/eventsub.ts +++ b/src/integrations/twitch/eventsub.ts @@ -27,6 +27,7 @@ export class TwitchEventSub extends ReconnectingSocket { // own seamless reconnect request) stays below the threshold and out of the journal private hadReadySession = false private connectionLostAt: number | null = null + private journalTimer: ReturnType<typeof setTimeout> | null = null protected journalAfterMs = 60_000 protected markReady(): void { @@ -35,29 +36,50 @@ export class TwitchEventSub extends ReconnectingSocket { } protected onOpen(_socket: WebSocket): void { - const lostAt = this.connectionLostAt + // the connection is back: whatever outage it ended, the alarm is not needed anymore. A line in the journal + // was already written by the timer if the outage crossed the threshold, so nothing is written here + if (this.journalTimer) { + clearTimeout(this.journalTimer) + this.journalTimer = null + } this.connectionLostAt = null - if (lostAt === null) return - - const downMs = Date.now() - lostAt - if (downMs < this.journalAfterMs) return - - logActivity({ - requestedBy: 'Twitch', - query: 'Twitch', - title: null, - videoId: null, - status: 'failed', - reasonCode: 'TWITCH_CONNECTION_LOST', - reasonParams: { minutes: Math.round(downMs / 60_000) } - }) } protected onClosed(): void { // an intentional stop loses nothing (stopped is set before the teardown): no alarm afterwards if (this.stopped) return - if (this.hadReadySession) this.connectionLostAt = Date.now() + + // only a session that was ready could lose something; the retries during an outage do not re-arm the alarm + const lostAt = this.hadReadySession ? Date.now() : null this.hadReadySession = false + if (lostAt === null) return + + this.connectionLostAt = lostAt + // the journal line is written when the outage crosses the threshold, not when it ends: a long one must be + // visible even if the connection never comes back. A blip cancels the timer in onOpen and stays unrecorded + if (this.journalTimer) clearTimeout(this.journalTimer) + this.journalTimer = setTimeout(() => { + this.journalTimer = null + logActivity({ + requestedBy: 'Twitch', + query: 'Twitch', + title: null, + videoId: null, + status: 'failed', + reasonCode: 'TWITCH_CONNECTION_LOST', + reasonParams: { minutes: Math.round((Date.now() - lostAt) / 60_000) } + }) + }, this.journalAfterMs) + } + + async disconnect(): Promise<void> { + // an intentional stop is not an outage: whatever alarm survived from it is cancelled here + if (this.journalTimer) { + clearTimeout(this.journalTimer) + this.journalTimer = null + } + this.connectionLostAt = null + await super.disconnect() } constructor(config: TwitchEventSubOptions) { @@ -124,8 +146,9 @@ export class TwitchEventSub extends ReconnectingSocket { this.markReady() this.watchKeepalive(message) } catch (error) { + // no log line here: the rejected connect() reaches the caller, and connectOrRetry() reports the same + // failure once, with the retry plan - two error lines for one subscription refusal is a duplicate const normalized = normalizeTwitchError(error) - log.error(`EventSub subscription failed: ${describeError(normalized)}`) this.abort(normalized) } } diff --git a/src/integrations/twitch/rewards.ts b/src/integrations/twitch/rewards.ts index d1ac638..ec33a61 100644 --- a/src/integrations/twitch/rewards.ts +++ b/src/integrations/twitch/rewards.ts @@ -21,12 +21,14 @@ export type RewardDto = { const TITLE_MAX_LENGTH = INPUT_LIMITS.rewardTitle const PROMPT_MAX_LENGTH = INPUT_LIMITS.rewardPrompt +const COST_MAX = INPUT_LIMITS.rewardCost -// the name of a limit in the panel, the switch that Twitch has for it, and the number that goes with the switch +// the name of a limit in the panel, the switch that Twitch has for it, the number that goes with the switch, +// and the app-side sanity cap for that number (see INPUT_LIMITS: Twitch documents the minimum only) const LIMITS = [ - { name: 'maxPerStream', flag: 'is_max_per_stream_enabled', value: 'max_per_stream' }, - { name: 'maxPerUserPerStream', flag: 'is_max_per_user_per_stream_enabled', value: 'max_per_user_per_stream' }, - { name: 'globalCooldownSeconds', flag: 'is_global_cooldown_enabled', value: 'global_cooldown_seconds' } + { name: 'maxPerStream', flag: 'is_max_per_stream_enabled', value: 'max_per_stream', cap: INPUT_LIMITS.rewardMaxPerStream }, + { name: 'maxPerUserPerStream', flag: 'is_max_per_user_per_stream_enabled', value: 'max_per_user_per_stream', cap: INPUT_LIMITS.rewardMaxPerUserPerStream }, + { name: 'globalCooldownSeconds', flag: 'is_global_cooldown_enabled', value: 'global_cooldown_seconds', cap: INPUT_LIMITS.rewardGlobalCooldownSeconds } ] as const export function toRewardDto(reward: TwitchCustomReward): RewardDto { @@ -73,7 +75,7 @@ export function parseRewardInput( } if (input.cost !== undefined || requireTitleAndCost) { - if (isPositiveInteger(input.cost)) payload.cost = input.cost + if (isPositiveInteger(input.cost) && input.cost <= COST_MAX) payload.cost = input.cost else reject('cost') } @@ -101,7 +103,7 @@ export function parseRewardInput( if (input.limits !== undefined && !isPlain(input.limits)) reject('limits') const limits = isPlain(input.limits) ? input.limits : {} - for (const { name, flag, value } of LIMITS) { + for (const { name, flag, value, cap } of LIMITS) { const limit = limits[name] if (limit === undefined) continue @@ -112,9 +114,9 @@ export function parseRewardInput( payload[flag] = limit.enabled - // the number counts only while its switch is on, and has to be a whole number above 0 then + // the number counts only while its switch is on, and has to be a whole number within its cap then if (!limit.enabled) continue - if (isPositiveInteger(limit.value)) payload[value] = limit.value + if (isPositiveInteger(limit.value) && limit.value <= cap) payload[value] = limit.value else reject(`limits.${name}.value`) } diff --git a/src/logger.ts b/src/logger.ts index 3fbbc13..caeca02 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -45,6 +45,11 @@ let logFile: string | null = null let fileSize: number | null = null let writeChain: Promise<void> = Promise.resolve() +// a rotation that keeps failing (the file is locked by an antivirus, a viewer) is retried at most this often: +// every line over the limit must not turn into another full rename cycle while the lock holds +const ROTATION_RETRY_INTERVAL_MS = 30_000 +let lastFailedRotationAt = 0 + export function enableFileLogging(): void { logFile = join(getDataDir(), 'logs', 'app.log') fileSize = null @@ -80,12 +85,23 @@ async function appendToFile(file: string, line: string): Promise<void> { const bytes = Buffer.byteLength(line) if (fileSize > 0 && fileSize + bytes > MAX_FILE_BYTES) { - // when the rotation did not happen, re-read the real size: the log keeps growing, and the next - // line over the limit retries the rotation instead of counting a nonexistent empty file - fileSize = (await rotate(file)) ? 0 : await stat(file).then( - (info) => info.size, - () => 0 - ) + // retried once per interval, not once per line: between the attempts the log keeps growing and this + // size keeps counting, so the limit condition stays honest and the next attempt lands on the first + // line over the limit after the pause + if (Date.now() - lastFailedRotationAt >= ROTATION_RETRY_INTERVAL_MS) { + if (await rotate(file)) { + lastFailedRotationAt = 0 + fileSize = 0 + } else { + lastFailedRotationAt = Date.now() + // re-read the real size: the log keeps growing, and the next line over the limit retries the + // rotation instead of counting a nonexistent empty file + fileSize = await stat(file).then( + (info) => info.size, + () => 0 + ) + } + } } await appendFile(file, line, 'utf8') diff --git a/src/server.ts b/src/server.ts index 828f1c4..f8acfcd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,52 +1,14 @@ -import express from 'express' -import path from 'node:path' - +import { createApp, listenOnFreePort } from './app.js' import { getServerConfig, updateServerConfig } from './server-config.js' -import { getAppRoot, loadEnvFile } from './runtime.js' +import { loadEnvFile } from './runtime.js' import { ensureDataDirectory } from './persist.js' import { initState } from './state-file.js' import { runStartupTasks } from './startup.js' -import { errorHandler } from './error-handler.js' -import { apiRouter } from './routes/index.js' import { initializeTwitchIntegration } from './integrations/twitch/index.js' import { getTwitchClientId } from './env.js' import { clearTwitchAuthorization } from './secrets.js' import { createLogger, describeError, enableFileLogging } from './logger.js' -import { hostCheck } from './host-check.js' -import { determineLocality } from './local-only.js' import { installShutdownHandlers, registerServerStop, shutdown } from './shutdown.js' -import type { Server } from 'node:http' - -/** The assembled app: nothing runs on import, so a test can build the app without starting it. */ -export function createApp(): express.Express { - const app = express() - const publicDir = path.join(getAppRoot(), 'public') - - app.use(hostCheck) - app.use(determineLocality) - app.use(express.json({ limit: '50kb' })) - app.use(express.static(publicDir, { extensions: ['html'] })) - - app.use('/api', apiRouter) - app.use(errorHandler) - return app -} - -async function listenOnFreePort(start: number): Promise<{ server: Server; port: number }> { - for (let port = start; port < start + 100; port++) { - try { - const server = await new Promise<Server>((resolve, reject) => { - const candidate = app.listen(port) - candidate.once('listening', () => resolve(candidate)) - candidate.once('error', reject) - }) - return { server, port } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error - } - } - throw new Error(`No free port in ${start}-${start + 99}`) -} const app = createApp() const log = createLogger('SERVER') @@ -66,7 +28,7 @@ async function main() { else log.warn('Twitch integration disabled: TWITCH_CLIENT_ID is not set') const preferredPort = getServerConfig().port - const { server, port } = await listenOnFreePort(preferredPort) + const { server, port } = await listenOnFreePort(app, preferredPort) // saved before the exit, a request accepted during the save would be lost after it registerServerStop(() => server.close()) diff --git a/src/validate.ts b/src/validate.ts index 9ccc5d4..fe65cb4 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -13,7 +13,16 @@ export const INPUT_LIMITS = { blocklistTitle: 200, chatMessage: 500, rewardTitle: 45, - rewardPrompt: 200 + rewardPrompt: 200, + // The Twitch reference (dev.twitch.tv/docs/api/reference, Create/Update Custom Reward) documents the + // minimums only: the cost and every redemption limit are "a whole number of at least 1" - no ceiling is + // documented. The cost and per-stream caps below are app-side sanity caps, not Twitch rules; whatever + // still slips past them reaches Twitch and comes back as TWITCH_API_ERROR without a field name. + rewardCost: 10_000_000, + rewardMaxPerStream: 100_000, + rewardMaxPerUserPerStream: 100_000, + // The cooldown input in the creator dashboard is capped at 14 days, and seconds are what the API takes + rewardGlobalCooldownSeconds: 1_209_600 } as const export function checkVideoId(value: unknown): asserts value is string { diff --git a/src/web-fetch.ts b/src/web-fetch.ts index c8edda4..0ade9ae 100644 --- a/src/web-fetch.ts +++ b/src/web-fetch.ts @@ -25,6 +25,8 @@ export async function fetchWithTimeout(url: string | URL, init: RequestInit | un try { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }) if (retry && attempt < retry.attempts && retry.shouldRetry({ status: response.status })) { + // the answer is about to be thrown away: cancel its unread body, or the connection is held until the timeout + await response.body?.cancel().catch(() => {}) await sleep(retry.delayMs(attempt)) continue } diff --git a/tests/backend/app.test.ts b/tests/backend/app.test.ts new file mode 100644 index 0000000..932be6b --- /dev/null +++ b/tests/backend/app.test.ts @@ -0,0 +1,31 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +// A listening net.Server shows up here, so "the app did not start" can be asserted, not just assumed +function tcpListenerCount(): number { + return process.getActiveResourcesInfo().filter((name) => name === 'TCPServerWrap').length +} + +test('createApp assembles the app without starting it', async (t) => { + // dynamic import on purpose: it happens after the baseline, so import-time side effects are caught too + const before = tcpListenerCount() + const { createApp, listenOnFreePort } = await import('../../src/app.js') + + await t.test('importing the module and building the app opens no listener', () => { + const app = createApp() + + assert.equal(typeof app, 'function', 'an express app is a callable') + assert.equal(typeof app.listen, 'function', 'listen() is exposed, not called') + assert.equal(tcpListenerCount(), before, 'building the app must not start a server') + }) + + await t.test('a real listener is visible to the same check', async () => { + const app = createApp() + // port 0 = an ephemeral port picked by the OS, nothing to collide with + const { server } = await listenOnFreePort(app, 0) + + assert.ok(tcpListenerCount() > before, 'the check above would be vacuous otherwise') + + await new Promise<void>((resolve) => server.close(() => resolve())) + }) +}) diff --git a/tests/backend/integrations/twitch/eventsub.test.ts b/tests/backend/integrations/twitch/eventsub.test.ts index 19303b3..e85c8f9 100644 --- a/tests/backend/integrations/twitch/eventsub.test.ts +++ b/tests/backend/integrations/twitch/eventsub.test.ts @@ -1,4 +1,4 @@ -import { test, after } from 'node:test' +import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -147,6 +147,24 @@ test('TwitchEventSub: connecting', async (t) => { const { sub } = await setup({ failSubscribe: new Error('subscription refused') }) await assert.rejects(sub.connect(), /subscription refused/) }) + + await t.test('a refused subscription is answered by the rejected connect(), not by an error line of its own', async () => { + const { sub } = await setup({ failSubscribe: new Error('subscription refused') }) + const lines: string[] = [] + const realError = console.error + console.error = (line: string) => { lines.push(line) } + + try { + await assert.rejects(sub.connect(), /subscription refused/) + await pause(20) // the teardown after the refusal finishes in the background + } finally { + console.error = realError + } + + // connectOrRetry() in the integration reports the same failure once, with the retry plan; the socket + // itself must stay silent, or every attempt is an error line twice over + assert.equal(lines.filter((line) => line.includes('subscription')).length, 0) + }) }) test('TwitchEventSub: a connection lost to the outside', async (t) => { @@ -192,6 +210,83 @@ test('TwitchEventSub: a connection lost to the outside', async (t) => { }) }) +test('TwitchEventSub: the outage line is written when the threshold is crossed, not when the outage ends', async (t) => { + // The real threshold is a minute; these subtests keep it real and drive the clock by hand instead. The + // socket talks to a local server over real I/O, so waiting is done with setImmediate - it survives the mocked timers + const drainUntil = async (condition: () => boolean, what: string) => { + for (let i = 0; i < 1000 && !condition(); i++) await new Promise((resolve) => setImmediate(resolve)) + if (!condition()) assert.fail(`timed out waiting for: ${what}`) + } + + const lostLines = () => getActivity().filter((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST') + + await t.test('an outage that never ends leaves exactly one line, written by the timer', async () => { + const { sub, server, login } = await setup() + ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 60_000 + await sub.connect() + login.refused = true // the saved login is gone: no reconnection will end the outage instead of the timer + clearActivity() + + mock.timers.enable({ apis: ['setTimeout'] }) + try { + server.dropClients() + await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') + + mock.timers.tick(60_001) + await drainUntil(() => lostLines().length === 1, 'the journal line') + + // still no second connection: the line is there anyway, and only one + assert.equal(server.connections(), 1) + } finally { + mock.timers.reset() + } + }) + + await t.test('a recovery under the threshold cancels the alarm and leaves the journal silent', async () => { + const { sub, server } = await setup() + ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 60_000 + clearActivity() + await sub.connect() + + mock.timers.enable({ apis: ['setTimeout'] }) + try { + server.dropClients() + await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') + + mock.timers.tick(5) // the 5 ms reconnect delay of the test timing: the recovery starts, the 60 s alarm stays pending + await drainUntil(() => sub.isConnected(), 'the session to be ready again') + assert.equal((sub as unknown as { journalTimer: unknown }).journalTimer, null, 'the recovery cancelled the alarm') + + mock.timers.tick(120_000) // had the alarm survived, its line would be written here + assert.equal(lostLines().length, 0) + } finally { + mock.timers.reset() + } + }) + + await t.test('a recovery over the threshold keeps the timer line, without a second one from the recovery', async () => { + const { sub, server } = await setup() + ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 60_000 + clearActivity() + await sub.connect() + + mock.timers.enable({ apis: ['setTimeout'] }) + try { + server.dropClients() + await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') + + // one tick passes both the reconnect delay (5 ms) and the journal threshold (60 s) + mock.timers.tick(60_001) + await drainUntil(() => lostLines().length === 1, 'the journal line') + await drainUntil(() => sub.isConnected(), 'the session to be ready again') + + assert.equal(lostLines().length, 1, 'exactly one line: the timer wrote it, the recovery added nothing') + } finally { + mock.timers.reset() + } + }) +}) + test('TwitchEventSub: redemptions', async (t) => { await t.test('a redemption notification reaches the handler', async () => { const { sub, server, events } = await setup() diff --git a/tests/backend/twitch-rewards.test.ts b/tests/backend/twitch-rewards.test.ts index 3326210..839667a 100644 --- a/tests/backend/twitch-rewards.test.ts +++ b/tests/backend/twitch-rewards.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { parseRewardInput, toRewardDto } from '../../src/integrations/twitch/rewards.js' +import { INPUT_LIMITS } from '../../src/validate.js' import type { TwitchCustomReward } from '../../src/integrations/twitch/types.js' const create = (body: unknown) => parseRewardInput(body, { requireTitleAndCost: true }) @@ -55,6 +56,11 @@ test('the title and the cost', async (t) => { for (const cost of [0, -5, 2.5, '100', null, Number.NaN]) assert.deepEqual(create({ title: 'a', cost }).rejected, ['cost'], String(cost)) assert.deepEqual(create({ title: 'a', cost: 1 }).rejected, []) }) + + await t.test('a cost over the sanity cap is refused by its own name, not left for Twitch to refuse', () => { + assert.deepEqual(create({ title: 'a', cost: INPUT_LIMITS.rewardCost }).rejected, []) + assert.deepEqual(create({ title: 'a', cost: INPUT_LIMITS.rewardCost + 1 }).rejected, ['cost']) + }) }) test('an update', async (t) => { @@ -110,6 +116,23 @@ test('the limits', async (t) => { } }) + await t.test('a limit over its cap is refused by its own name, not left for Twitch to refuse', () => { + const caps = { + maxPerStream: INPUT_LIMITS.rewardMaxPerStream, + maxPerUserPerStream: INPUT_LIMITS.rewardMaxPerUserPerStream, + globalCooldownSeconds: INPUT_LIMITS.rewardGlobalCooldownSeconds + } + + assert.deepEqual(update({ limits: { maxPerStream: { enabled: true, value: caps.maxPerStream } } }).rejected, []) + for (const [name, cap] of Object.entries(caps)) { + assert.deepEqual( + update({ limits: { [name]: { enabled: true, value: cap + 1 } } }).rejected, + [`limits.${name}.value`], + name + ) + } + }) + await t.test('a number behind a switch that is off is not looked at', () => { assert.deepEqual(update({ limits: { maxPerStream: { enabled: false, value: 'junk' } } }).rejected, []) }) diff --git a/tests/backend/web-fetch.test.ts b/tests/backend/web-fetch.test.ts new file mode 100644 index 0000000..1b5b319 --- /dev/null +++ b/tests/backend/web-fetch.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fetchWithTimeout, retryableTransport } from '../../src/web-fetch.js' + +// The transport is a thin skeleton, but its retry path has one job worth locking in: a thrown-away answer must +// not hold the connection (its body is cancelled), while the final answer comes back readable. +test('fetchWithTimeout: the retry path', async (t) => { + const realFetch = globalThis.fetch + + await t.test('cancels the body of the retried answer and returns the final one readable', async () => { + let cancelled = 0 + let calls = 0 + + globalThis.fetch = (async () => { + calls++ + if (calls === 1) { + const body = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode('rate limited')) + // never closed on purpose: a body that waits to be read, like a real one + } + }) + const response = new Response(body, { status: 429 }) + const cancel = response.body!.cancel.bind(response.body) + response.body!.cancel = () => { + cancelled++ + return cancel() + } + return response + } + return new Response('ok', { status: 200 }) + }) as typeof fetch + + try { + const response = await fetchWithTimeout( + 'https://example.test/flaky', + undefined, + 1000, + { attempts: 2, delayMs: () => 0, shouldRetry: retryableTransport } + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'ok', 'the final answer is returned with a readable body') + assert.equal(cancelled, 1, 'only the thrown-away answer has its body cancelled') + } finally { + globalThis.fetch = realFetch + } + }) +}) From e5df8fcf0e6d4bb45290fe4539e872079fc890d8 Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:53:20 +0700 Subject: [PATCH 20/22] Refactor Twitch integration into focused modules Integration: - separate chat commands, device authorization, redemption handling, and shared runtime state from the integration lifecycle - centralize retry and connection tunables Testing: - update tests to target the extracted modules --- src/integrations/twitch/chat-commands.ts | 145 +++++ src/integrations/twitch/device-auth.ts | 146 +++++ src/integrations/twitch/error.ts | 14 + src/integrations/twitch/index.ts | 588 ++---------------- src/integrations/twitch/redemptions.ts | 203 ++++++ src/integrations/twitch/state.ts | 69 ++ src/routes/twitch.ts | 9 +- .../integrations/twitch/chat-commands.test.ts | 87 +-- .../integrations/twitch/device-auth.test.ts | 16 +- .../integrations/twitch/redemptions.test.ts | 45 +- 10 files changed, 726 insertions(+), 596 deletions(-) create mode 100644 src/integrations/twitch/chat-commands.ts create mode 100644 src/integrations/twitch/device-auth.ts create mode 100644 src/integrations/twitch/redemptions.ts create mode 100644 src/integrations/twitch/state.ts diff --git a/src/integrations/twitch/chat-commands.ts b/src/integrations/twitch/chat-commands.ts new file mode 100644 index 0000000..777d38a --- /dev/null +++ b/src/integrations/twitch/chat-commands.ts @@ -0,0 +1,145 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { TwitchChatMessage, TwitchChatPermission } from './types.js' +import { chatConnection } from './state.js' +import { + buildNowPlayingMessage, + buildQueueMessage, + buildSkipMessage +} from '../../chat-replies.js' +import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' +import { getState, skipCurrent, setPaused } from '../../player.js' +import { getSettings } from '../../settings.js' +import { t } from '../../i18n.js' +import { createLogger, describeError } from '../../logger.js' + +// Chat commands of the queue: matching, permissions, cooldowns and the replies. The chat +// socket itself belongs to the lifecycle in index.ts; this module reads its connection +// state (from state.ts) to wait for it before answering. + +const chatLog = createLogger('TWITCH CHAT') + +/** Answers whether the sender of the message may use a command with this permission level. */ +export function hasPermission(message: TwitchChatMessage, permission: TwitchChatPermission): boolean { + switch (permission) { + case 'everyone': + return true + case 'moderator': + return message.isModerator || message.isBroadcaster + case 'broadcaster': + return message.isBroadcaster + } +} + +// A command word must be the whole message (or the first word, for forward-compatibility with arguments), +// case-insensitive - "!sg skip" matches "!sg skip", "!SG SKIP", and "!sg skip please" +// but not "!sg skipping" or a message that merely contains it midway through +/** Matches a message against a command word: whole-message or first-word, case-insensitive. */ +export function matchesCommand(text: string, command: string): boolean { + const normalized = text.trim().toLowerCase().replace(/\s+/g, ' ') + return normalized === command || normalized.startsWith(`${command} `) +} + +const CHAT_CONNECT_POLL_MS = 250 + +async function waitForChat(limitMs: number): Promise<boolean> { + const deadline = Date.now() + limitMs + + while (true) { + const chat = chatConnection.instance + if (!chat) return false + if (chat.isConnected()) return true + + const remaining = deadline - Date.now() + if (remaining <= 0) return false + await sleep(Math.min(CHAT_CONNECT_POLL_MS, remaining)) + } +} + +/** Sends a message through the chat socket of the connected account, waiting out a start or a reconnect. */ +export async function replyInChat(message: string): Promise<void> { + const chat = chatConnection.instance + if (!chat) return + + if (!chat.isConnected()) { + const connected = await waitForChat(chatConnection.starting ? chatConnection.startWaitMs : chatConnection.reconnectWaitMs) + if (!connected) { + chatLog.warn(`Chat not connected, skipping reply: ${message}`) + return + } + } + + await chat.sendMessage(message).catch((error) => { + chatLog.error(`Failed to send chat reply: ${describeError(error)}`) + }) +} + +// One global (not per-user) cooldown per kind of command: two moderators can't double up on skip/pause/resume, +// and now/queue can't be spammed +const lastRun = { plainCooldownSeconds: 0, controlCooldownSeconds: 0 } + +async function runCommand(message: TwitchChatMessage, cooldown: keyof typeof lastRun, cooldownSeconds: number, action: () => string): Promise<void> { + const now = Date.now() + if (now - lastRun[cooldown] < cooldownSeconds * 1000) { + chatLog.info(`Ignored a command from ${message.displayName}: cooldown active`) + return + } + lastRun[cooldown] = now + + await replyInChat(action()) +} + +const COMMANDS: Record<(typeof CHAT_COMMAND_KEYS)[number], { cooldown: keyof typeof lastRun; run: () => string }> = { + now: { + cooldown: 'plainCooldownSeconds', + run: () => buildNowPlayingMessage(getState(), getSettings().locale) + }, + queue: { + cooldown: 'plainCooldownSeconds', + run: () => buildQueueMessage(getState(), getSettings().locale) + }, + skip: { + cooldown: 'controlCooldownSeconds', + run: () => { + skipCurrent() + return buildSkipMessage(getState(), getSettings().locale) + } + }, + pause: { + cooldown: 'controlCooldownSeconds', + run: () => { + setPaused(true) + return t(getSettings().locale, 'chat.paused') + } + }, + resume: { + cooldown: 'controlCooldownSeconds', + run: () => { + setPaused(false) + return t(getSettings().locale, 'chat.resumed') + } + } +} + +/** Handles one channel message: the first enabled, matching and permitted command runs, anything else returns. */ +export async function handleChatMessage(message: TwitchChatMessage): Promise<void> { + // every command starts with "!" (the config schema enforces the prefix): any other channel talk + // is over before it could cost a config read or a command comparison + if (!message.text.startsWith('!')) return + + const commands = getTwitchConfig().chatCommands + + for (const key of CHAT_COMMAND_KEYS) { + const { enabled, command, permission } = commands[key] + if (!enabled || !matchesCommand(message.text, command) || !hasPermission(message, permission)) continue + + chatLog.info(`Command "${command}" from ${message.displayName}`) + const { cooldown, run } = COMMANDS[key] + await runCommand(message, cooldown, commands[cooldown], run) + return + } +} + +/** @internal Clears the command cooldowns (used by the integration reset and the tests). */ +export function resetCommandCooldowns(): void { + lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 +} diff --git a/src/integrations/twitch/device-auth.ts b/src/integrations/twitch/device-auth.ts new file mode 100644 index 0000000..3fec7a2 --- /dev/null +++ b/src/integrations/twitch/device-auth.ts @@ -0,0 +1,146 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { TwitchDeviceCodeResponse, TwitchUserInfo } from './types.js' +import { getRuntime, twitchBackoff } from './state.js' +import { isRetryable } from './error.js' +import { clearTwitchAuthorization, clearTwitchOAuthState, getPublicSecretsView, updateTwitchOAuthState } from '../../secrets.js' +import { notify } from '../../state-events.js' +import { AppError } from '../../types.js' +import { createLogger, describeError } from '../../logger.js' + +// The device-code login: the panel asks for a code, the viewer enters it on twitch.tv/activate, +// and the poll that waits for it turns into a connected account - or rolls the tokens back. +// The lifecycle (index.ts) owns the public entry point and hands this module the socket starter, +// so the module does not need to import it back. + +const log = createLogger('TWITCH') + +const USER_INFO_RETRY_ATTEMPTS = 3 + +let deviceAuthorizationPromise: Promise<unknown> | null = null +let deviceAuthorizationGeneration = 0 + +// What a login that waits for the code finds out is not used any more once it is cancelled (it only logs that it was) +export function cancelDeviceAuthorization(): void { + if (deviceAuthorizationPromise) log.info('Device authorization cancelled') + + deviceAuthorizationGeneration++ + deviceAuthorizationPromise = null + clearTwitchAuthorization() +} + +/** + * Starts the device-code login: a code is requested for the panel, and the returned promise settles + * once the code is on its way (the login itself keeps running in the background and reports itself). + */ +export async function runDeviceAuthorization(startSockets: () => Promise<void>): Promise<TwitchDeviceCodeResponse> { + const runtime = getRuntime() + if (!runtime) { + throw new AppError('TWITCH_AUTH_ERROR') + } + + if (getPublicSecretsView().twitch.connected) { + throw new AppError('TWITCH_AUTH_ERROR', 'Twitch account is already connected') + } + + // a closed dialog must not lock the next attempt for the minutes the old code stays valid: a new start replaces the waiting one + cancelDeviceAuthorization() + const generation = deviceAuthorizationGeneration + + const device = await runtime.oauth.requestDeviceCode() + // another start (or a disconnect) came while the code was being requested: this one is no longer wanted + if (generation !== deviceAuthorizationGeneration) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch authorization was cancelled') + + // what the panel shows while the login waits (also after the page is reloaded): it ends with the login, however it ends + updateTwitchOAuthState({ + authorization: { userCode: device.user_code, verificationUri: device.verification_uri, expiresAt: Date.now() + device.expires_in * 1000 } + }) + + deviceAuthorizationPromise = runtime.oauth + .pollForToken(device.device_code, device.interval, device.expires_in, () => generation === deviceAuthorizationGeneration) + .then(async () => { + if (generation !== deviceAuthorizationGeneration) { + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } + const current = getRuntime() + if (!current) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client is not initialized') + + let fetchedUserInfo: TwitchUserInfo | null = null + let lastError: unknown = null + + for (let attempt = 1; attempt <= USER_INFO_RETRY_ATTEMPTS; attempt++) { + if (generation !== deviceAuthorizationGeneration) { + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } + + try { + fetchedUserInfo = await current.client.getUserInfo() + break + } catch (error) { + lastError = error + // a refusal (4xx) or a refused token gives the same answer every time: repeating it only delays the rollback + if (attempt === USER_INFO_RETRY_ATTEMPTS || !isRetryable(error)) break + log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) + await sleep(twitchBackoff.delayMs * 2 ** (attempt - 1)) + } + } + + if (!fetchedUserInfo) { + log.error(`getUserInfo failed: ${describeError(lastError)}`) + if (generation === deviceAuthorizationGeneration) { + await current.oauth.revokeTokens() + current.oauth.clearTokenData() + clearTwitchOAuthState() + throw lastError instanceof AppError + ? lastError + : new AppError('TWITCH_AUTH_ERROR', 'Failed to fetch user info after successful login. Please try connecting again.') + } + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } + + if (generation !== deviceAuthorizationGeneration) { + throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') + } + + updateTwitchOAuthState({ + userInfo: fetchedUserInfo, + connectedAt: Date.now() + }) + + await startSockets() + + log.info(`Twitch account connected: ${fetchedUserInfo.displayName}`) + return fetchedUserInfo + }) + // nobody awaits this promise: a refusal, an expired code or a cancel is logged here instead of becoming an unhandled rejection + .catch((error) => { + if (generation !== deviceAuthorizationGeneration) { + log.info('Twitch authorization was cancelled') + return + } + + log.error(`Twitch authorization failed: ${describeError(error)}`) + + // a non-AppError is a bug: it stays in the log above, and the panel gets the generic auth failure + const failure = error instanceof AppError ? error : undefined + notify({ + type: 'error', + level: 'error', + code: failure?.code ?? 'TWITCH_AUTH_ERROR', + params: failure?.toParams() + }) + }) + .finally(() => { + if (generation !== deviceAuthorizationGeneration) return + + deviceAuthorizationPromise = null + clearTwitchAuthorization() + }) + + return device +} + +/** @internal Forgets a waiting login (used by the integration reset). */ +export function resetDeviceAuthorization(): void { + deviceAuthorizationPromise = null + deviceAuthorizationGeneration = 0 +} diff --git a/src/integrations/twitch/error.ts b/src/integrations/twitch/error.ts index 9355a6e..b64391d 100644 --- a/src/integrations/twitch/error.ts +++ b/src/integrations/twitch/error.ts @@ -1,4 +1,5 @@ import { AppError } from '../../types.js' +import { retryableTransport } from '../../web-fetch.js' // `transient: 1` on an error's params says the same attempt may work later (a lost connection, a login that // could not be refreshed just now), so a retry is worth it. The literal lives here alone, and the error is @@ -30,3 +31,16 @@ export function normalizeTwitchError(error: unknown): AppError { return new AppError('TWITCH_UNKNOWN_ERROR', String(error)) } + +// A repeat helps only when the failure can pass by itself: a lost connection or a timeout (they arrive as +// plain errors where nothing normalized them on the way), a 429, a 5xx, or a login that could not be +// refreshed right now (flagged `transient`). A refusal (4xx), a missing login or a token Twitch no longer +// accepts gives the same answer every time, and any other error is a bug: repeating it would only repeat the bug +export function isRetryable(error: unknown): boolean { + if (!(error instanceof AppError)) return isNetworkError(error) + if (error.params?.transient === 1) return true + + // the same transport rule the fetch skeleton retries by: a 429, a 5xx + const status = error.params?.status + return typeof status === 'number' && retryableTransport({ status }) +} diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 9f7b4f4..0903062 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -3,78 +3,61 @@ import { TwitchClient } from './client.js' import { TwitchEventSub } from './eventsub.js' import { TwitchChat } from './chat.js' import { ReconnectingSocket } from './socket.js' -import { retryableTransport } from '../../web-fetch.js' -import { setTimeout as sleep } from 'node:timers/promises' import type { TwitchAuthConfig, - TwitchChannelPointsRedemption, TwitchUserInfo, - TwitchDeviceCodeResponse, - TwitchChatMessage, - TwitchChatPermission + TwitchDeviceCodeResponse } from './types.js' -import { clearTwitchAuthorization, clearTwitchOAuthState, getPublicSecretsView, getSecrets, updateTwitchOAuthState } from '../../secrets.js' +import { clearTwitchOAuthState, getSecrets, updateTwitchOAuthState } from '../../secrets.js' import { detachChannelPointsRedemptions } from '../../queue.js' -import { requestSong } from '../../request.js' -import { - buildNowPlayingMessage, - buildQueueMessage, - buildSkipMessage, - buildRedemptionAcceptedMessage, - buildRedemptionRejectionMessage, - buildRedemptionRefundFailedMessage -} from '../../chat-replies.js' -import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' -import { getState, skipCurrent, setPaused } from '../../player.js' -import { registerRedemptionHandler, type RedemptionOutcome } from '../../finish.js' -import { AppError, type AddedSong, type FailureReason } from '../../types.js' -import type { QueueItem } from '../../types.js' +import { registerRedemptionHandler } from '../../finish.js' +import { AppError } from '../../types.js' import { createLogger, describeError } from '../../logger.js' -import { notify } from '../../state-events.js' -import { normalizeTwitchError, isNetworkError } from './error.js' -import { getSettings } from '../../settings.js' -import { t } from '../../i18n.js' +import { normalizeTwitchError } from './error.js' +import { + getRuntime, + setRuntime, + eventSubConnection, + chatConnection, + resetTunables, + type SocketState +} from './state.js' +import { handleChatMessage, resetCommandCooldowns } from './chat-commands.js' +import { handleChannelPointsRedemption, handleChannelPointsPlaybackOutcome, resetHandledRedemptions } from './redemptions.js' +import { cancelDeviceAuthorization, runDeviceAuthorization, resetDeviceAuthorization } from './device-auth.js' const log = createLogger('TWITCH') const chatLog = createLogger('TWITCH CHAT') -const REDEMPTION_RETRY_ATTEMPTS = 3 -const REDEMPTION_RETRY_DELAY_MS = 1000 -const USER_INFO_RETRY_ATTEMPTS = 3 -let retryDelayMs = REDEMPTION_RETRY_DELAY_MS - -let oauth: TwitchOAuth | null = null -let client: TwitchClient | null = null // The two long-lived sockets of a connected account, written once and driven the same way everywhere: -// start, stop and the test reset walk the list instead of naming each socket three times over +// start, stop and the test reset walk the list instead of naming each socket three times over. +// The live state of each entry lives in state.ts (chat-commands reads the chat one to wait it out) interface ManagedSocket<T extends ReconnectingSocket> { name: string logger: ReturnType<typeof createLogger> create: () => T | null - instance: T | null - starting: Promise<void> | null + state: SocketState<T> } const eventSubSocket: ManagedSocket<TwitchEventSub> = { name: 'Twitch EventSub', logger: log, - instance: null, - starting: null, + state: eventSubConnection, create: () => { - const twitchClient = client - if (!twitchClient?.getCachedUserInfo()) { + const runtime = getRuntime() + if (!runtime?.client.getCachedUserInfo()) { log.info('Twitch account not connected, EventSub will not start') return null } - if (!oauth?.getConfig().clientId) { + if (!runtime.oauth.getConfig().clientId) { log.info('Twitch client ID not configured, EventSub will not start') return null } return new TwitchEventSub({ - client: twitchClient, - onChannelPointsRedemption: (event) => handleChannelPointsRedemption(event, twitchClient) + client: runtime.client, + onChannelPointsRedemption: (event) => handleChannelPointsRedemption(event, runtime.client) }) } } @@ -82,11 +65,11 @@ const eventSubSocket: ManagedSocket<TwitchEventSub> = { const chatSocket: ManagedSocket<TwitchChat> = { name: 'Twitch chat', logger: chatLog, - instance: null, - starting: null, + state: chatConnection, create: () => { - const userInfo = client?.getCachedUserInfo() - if (!oauth || !userInfo) { + const runtime = getRuntime() + const userInfo = runtime?.client.getCachedUserInfo() + if (!runtime || !userInfo) { chatLog.info('Twitch account not connected, chat will not start') return null } @@ -94,7 +77,7 @@ const chatSocket: ManagedSocket<TwitchChat> = { // Started whenever the account is connected, with or without enabled commands: the replies to redemptions // (accepted, refunded) go through the chat too, and which commands react is decided per message return new TwitchChat({ - oauth, + oauth: runtime.oauth, channelLogin: userInfo.login, onMessage: handleChatMessage }) @@ -103,42 +86,8 @@ const chatSocket: ManagedSocket<TwitchChat> = { const managedSockets: ManagedSocket<ReconnectingSocket>[] = [eventSubSocket, chatSocket] -function currentChat(): TwitchChat | null { - return chatSocket.instance -} -let deviceAuthorizationPromise: Promise<unknown> | null = null -let deviceAuthorizationGeneration = 0 - -// the ids of the redemptions handled so far (the latest ones only) -const HANDLED_REDEMPTIONS_LIMIT = 500 -const handledRedemptions = new Set<string>() -const lastRun = { plainCooldownSeconds: 0, controlCooldownSeconds: 0 } - -// what is kept with a queued track about the redemption that paid for it -type TrackedRedemption = NonNullable<QueueItem['channelPointsRedemption']> - -const trackRedemption = (event: TwitchChannelPointsRedemption): TrackedRedemption => ({ - id: event.id, - rewardId: event.reward.id, - userName: event.user_name -}) - -function handleChannelPointsPlaybackOutcome(tracked: TrackedRedemption, outcome: RedemptionOutcome, twitchClient: TwitchClient): void { - if (outcome.status === 'failed') { - void cancelRedemption(tracked, twitchClient, outcome.reason) - return - } - - if (!getTwitchConfig().autoFulfillRedemptions) { - log.info(`Redemption ${tracked.id} finished playback, left unfulfilled (auto-fulfill disabled)`) - return - } - - void fulfillRedemption(tracked, twitchClient) -} - export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = {}): void { - if (oauth) { + if (getRuntime()) { log.info('Twitch integration already initialized') return } @@ -148,7 +97,7 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = return } - oauth = new TwitchOAuth({ + const oauth = new TwitchOAuth({ clientId: config.clientId, scopes: config.scopes, onTokenUpdated: (tokenData) => { @@ -156,7 +105,7 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = } }) const twitchClient = new TwitchClient(oauth) - client = twitchClient + setRuntime({ oauth, client: twitchClient }) const secrets = getSecrets() @@ -177,67 +126,6 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = void startSockets() } -async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemption, twitchClient: TwitchClient): Promise<void> { - const configuredRewardId = getTwitchConfig().channelPointsRewardId - if (!configuredRewardId || event.reward.id !== configuredRewardId) { - // the subscription sees every redemption of the channel, most are other rewards' business - log.debug(`Ignoring redemption with non-matching reward ID: ${event.reward.id} (configured: ${configuredRewardId || 'none'})`) - return - } - - // a repeated delivery of the same redemption must not take the points twice - if (handledRedemptions.has(event.id)) { - log.info(`Ignoring redemption ${event.id}: it was already handled`) - return - } - handledRedemptions.add(event.id) - if (handledRedemptions.size > HANDLED_REDEMPTIONS_LIMIT) handledRedemptions.delete(handledRedemptions.values().next().value as string) - - log.info(`Channel Points redemption: ${event.reward.title} by ${event.user_name}`) - - const tracked = trackRedemption(event) - - const query = event.user_input.trim() - if (!query) { - log.warn(`Empty song request in redemption: ${event.id}`) - await cancelRedemption(tracked, twitchClient, { code: 'SONG_NOT_FOUND' }) - return - } - - let added: AddedSong - try { - // only the request itself: past this point the track is queued and the points are spent, so a later - // failure must not refund them (the track would play for free and close a refunded reward) - added = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) - } catch (error) { - // a refusal (a duplicate, a limit, a filter) is routine: the points go back and the viewer is told why. - // Anything else is an outage: this catch is the only place its details are logged - if (error instanceof AppError) { - log.warn(`Song request failed for redemption ${event.id}: ${describeError(error)}`) - } else { - log.error(`Song request failed for redemption ${event.id}: ${describeError(error, true)}`) - } - - const reason: FailureReason = error instanceof AppError ? error : { code: 'SERVER_ERROR' } - await cancelRedemption(tracked, twitchClient, reason) - return - } - - notify({ type: 'viewerRequest', user: event.user_name, title: added.song.title }) - - // The account can be disconnected while the song is being looked up. The track is queued by now, but there is - // no login left to close the redemption with, so it is left for the streamer like the others - if (!oauth?.isAuthenticated()) { - const detached = detachChannelPointsRedemptions() - log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) - return - } - - log.info(`Redemption ${event.id} added to queue`) - const locale = getSettings().locale - await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) -} - // Without a network at startup the integration must not stay dead: the same instance keeps retrying by itself async function connectOrRetry(socket: ReconnectingSocket, name: string, logger: ReturnType<typeof createLogger>): Promise<void> { try { @@ -250,37 +138,12 @@ async function connectOrRetry(socket: ReconnectingSocket, name: string, logger: } } -function hasPermission(message: TwitchChatMessage, permission: TwitchChatPermission): boolean { - switch (permission) { - case 'everyone': - return true - case 'moderator': - return message.isModerator || message.isBroadcaster - case 'broadcaster': - return message.isBroadcaster - } -} - -// A command word must be the whole message (or the first word, for forward-compatibility with arguments), -// case-insensitive - "!sg skip" matches "!sg skip", "!SG SKIP", and "!sg skip please" -// but not "!sg skipping" or a message that merely contains it midway through -function matchesCommand(text: string, command: string): boolean { - const normalized = text.trim().toLowerCase().replace(/\s+/g, ' ') - return normalized === command || normalized.startsWith(`${command} `) -} - -const CHAT_START_WAIT_MS = 5000 -const CHAT_RECONNECT_WAIT_MS = 60000 -const CHAT_CONNECT_POLL_MS = 250 -let chatStartWaitMs = CHAT_START_WAIT_MS -let chatReconnectWaitMs = CHAT_RECONNECT_WAIT_MS - async function connectSocket(entry: ManagedSocket<ReconnectingSocket>): Promise<void> { - if (!oauth || !client) return + if (!getRuntime()) return // a socket that says it cannot exist yet (no account, no client id) logs its own reason and stays null. // The instance is captured: a test mock that replaces it mid-start must not redirect this connection - const socket = (entry.instance ??= entry.create()) + const socket = (entry.state.instance ??= entry.create()) if (!socket) return if (!socket.isConnected()) await connectOrRetry(socket, entry.name, entry.logger) @@ -288,10 +151,10 @@ async function connectSocket(entry: ManagedSocket<ReconnectingSocket>): Promise< // One start per socket at a time: a second caller joins the start already running instead of racing it function startSocket(entry: ManagedSocket<ReconnectingSocket>): Promise<void> { - entry.starting ??= connectSocket(entry).finally(() => { - entry.starting = null + entry.state.starting ??= connectSocket(entry).finally(() => { + entry.state.starting = null }) - return entry.starting + return entry.state.starting } /** Starts the sockets of the connected account: each one's setup begins at once, as before (two voids became one call). */ @@ -299,302 +162,9 @@ async function startSockets(): Promise<void> { await Promise.all(managedSockets.map((entry) => startSocket(entry))) } -async function waitForChat(limitMs: number): Promise<boolean> { - const deadline = Date.now() + limitMs - - while (true) { - const chat = currentChat() - if (!chat) return false - if (chat.isConnected()) return true - - const remaining = deadline - Date.now() - if (remaining <= 0) return false - await sleep(Math.min(CHAT_CONNECT_POLL_MS, remaining)) - } -} - -// One global (not per-user) cooldown per kind of command: two moderators can't double up on skip/pause/resume, -// and now/queue can't be spammed -async function runCommand(message: TwitchChatMessage, cooldown: keyof typeof lastRun, cooldownSeconds: number, action: () => string): Promise<void> { - const now = Date.now() - if (now - lastRun[cooldown] < cooldownSeconds * 1000) { - chatLog.info(`Ignored a command from ${message.displayName}: cooldown active`) - return - } - lastRun[cooldown] = now - - await replyInChat(action()) -} - -const COMMANDS: Record<(typeof CHAT_COMMAND_KEYS)[number], { cooldown: keyof typeof lastRun; run: () => string }> = { - now: { - cooldown: 'plainCooldownSeconds', - run: () => buildNowPlayingMessage(getState(), getSettings().locale) - }, - queue: { - cooldown: 'plainCooldownSeconds', - run: () => buildQueueMessage(getState(), getSettings().locale) - }, - skip: { - cooldown: 'controlCooldownSeconds', - run: () => { - skipCurrent() - return buildSkipMessage(getState(), getSettings().locale) - } - }, - pause: { - cooldown: 'controlCooldownSeconds', - run: () => { - setPaused(true) - return t(getSettings().locale, 'chat.paused') - } - }, - resume: { - cooldown: 'controlCooldownSeconds', - run: () => { - setPaused(false) - return t(getSettings().locale, 'chat.resumed') - } - } -} - -async function handleChatMessage(message: TwitchChatMessage): Promise<void> { - // every command starts with "!" (the config schema enforces the prefix): any other channel talk - // is over before it could cost a config read or a command comparison - if (!message.text.startsWith('!')) return - - const commands = getTwitchConfig().chatCommands - - for (const key of CHAT_COMMAND_KEYS) { - const { enabled, command, permission } = commands[key] - if (!enabled || !matchesCommand(message.text, command) || !hasPermission(message, permission)) continue - - chatLog.info(`Command "${command}" from ${message.displayName}`) - const { cooldown, run } = COMMANDS[key] - await runCommand(message, cooldown, commands[cooldown], run) - return - } -} - -async function replyInChat(message: string): Promise<void> { - const chat = currentChat() - if (!chat) return - - if (!chat.isConnected()) { - const connected = await waitForChat(chatSocket.starting ? chatStartWaitMs : chatReconnectWaitMs) - if (!connected) { - chatLog.warn(`Chat not connected, skipping reply: ${message}`) - return - } - } - - await chat.sendMessage(message).catch((error) => { - chatLog.error(`Failed to send chat reply: ${describeError(error)}`) - }) -} - -// What a login that waits for the code finds out is not used any more once it is cancelled (it only logs that it was) -function cancelDeviceAuthorization(): void { - if (deviceAuthorizationPromise) log.info('Device authorization cancelled') - - deviceAuthorizationGeneration++ - deviceAuthorizationPromise = null - clearTwitchAuthorization() -} - -export async function startDeviceAuthorization(): Promise<TwitchDeviceCodeResponse> { - if (!oauth || !client) { - throw new AppError('TWITCH_AUTH_ERROR') - } - - if (getPublicSecretsView().twitch.connected) { - throw new AppError('TWITCH_AUTH_ERROR', 'Twitch account is already connected') - } - - // a closed dialog must not lock the next attempt for the minutes the old code stays valid: a new start replaces the waiting one - cancelDeviceAuthorization() - const generation = deviceAuthorizationGeneration - - const device = await oauth.requestDeviceCode() - // another start (or a disconnect) came while the code was being requested: this one is no longer wanted - if (generation !== deviceAuthorizationGeneration) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch authorization was cancelled') - - // what the panel shows while the login waits (also after the page is reloaded): it ends with the login, however it ends - updateTwitchOAuthState({ - authorization: { userCode: device.user_code, verificationUri: device.verification_uri, expiresAt: Date.now() + device.expires_in * 1000 } - }) - - deviceAuthorizationPromise = oauth - .pollForToken(device.device_code, device.interval, device.expires_in, () => generation === deviceAuthorizationGeneration) - .then(async () => { - if (generation !== deviceAuthorizationGeneration) { - throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') - } - if (!oauth || !client) throw new AppError('TWITCH_AUTH_ERROR', 'Twitch client is not initialized') - - let fetchedUserInfo: TwitchUserInfo | null = null - let lastError: unknown = null - - for (let attempt = 1; attempt <= USER_INFO_RETRY_ATTEMPTS; attempt++) { - if (generation !== deviceAuthorizationGeneration) { - throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') - } - - try { - fetchedUserInfo = await client.getUserInfo() - break - } catch (error) { - lastError = error - // a refusal (4xx) or a refused token gives the same answer every time: repeating it only delays the rollback - if (attempt === USER_INFO_RETRY_ATTEMPTS || !isRetryable(error)) break - log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) - await sleep(retryDelayMs * 2 ** (attempt - 1)) - } - } - - if (!fetchedUserInfo) { - log.error(`getUserInfo failed: ${describeError(lastError)}`) - if (generation === deviceAuthorizationGeneration) { - await oauth.revokeTokens() - oauth.clearTokenData() - clearTwitchOAuthState() - throw lastError instanceof AppError - ? lastError - : new AppError('TWITCH_AUTH_ERROR', 'Failed to fetch user info after successful login. Please try connecting again.') - } - throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') - } - - if (generation !== deviceAuthorizationGeneration) { - throw new AppError('TWITCH_AUTH_ERROR', 'Device authorization was cancelled') - } - - updateTwitchOAuthState({ - userInfo: fetchedUserInfo, - connectedAt: Date.now() - }) - - await startSockets() - - log.info(`Twitch account connected: ${fetchedUserInfo.displayName}`) - return fetchedUserInfo - }) - // nobody awaits this promise: a refusal, an expired code or a cancel is logged here instead of becoming an unhandled rejection - .catch((error) => { - if (generation !== deviceAuthorizationGeneration) { - log.info('Twitch authorization was cancelled') - return - } - - log.error(`Twitch authorization failed: ${describeError(error)}`) - - // a non-AppError is a bug: it stays in the log above, and the panel gets the generic auth failure - const failure = error instanceof AppError ? error : undefined - notify({ - type: 'error', - level: 'error', - code: failure?.code ?? 'TWITCH_AUTH_ERROR', - params: failure?.toParams() - }) - }) - .finally(() => { - if (generation !== deviceAuthorizationGeneration) return - - deviceAuthorizationPromise = null - clearTwitchAuthorization() - }) - - return device -} - -// A repeat helps only when the failure can pass by itself: a lost connection or a timeout (they arrive as -// plain errors where nothing normalized them on the way), a 429, a 5xx, or a login that could not be -// refreshed right now (flagged `transient`). A refusal (4xx), a missing login or a token Twitch no longer -// accepts gives the same answer every time, and any other error is a bug: repeating it would only repeat the bug -export function isRetryable(error: unknown): boolean { - if (!(error instanceof AppError)) return isNetworkError(error) - if (error.params?.transient === 1) return true - - // the same transport rule the fetch skeleton retries by: a 429, a 5xx - const status = error.params?.status - return typeof status === 'number' && retryableTransport({ status }) -} - -// Twitch answers 404 when there is no UNFULFILLED redemption with that id: the streamer already closed it in the rewards -// queue, or the reward was deleted. (A 400 means the request itself is wrong, so it stays a failure) -const isNoLongerOpen = (error: unknown): boolean => error instanceof AppError && error.params?.status === 404 - -type RedemptionUpdate = 'done' | 'no-longer-open' | 'failed' - -// Sets the status with retries and backoff. Returns whether Twitch accepted it -async function setRedemptionStatus( - redemption: TrackedRedemption, - twitchClient: TwitchClient, - status: 'FULFILLED' | 'CANCELED' -): Promise<RedemptionUpdate> { - for (let attempt = 1; attempt <= REDEMPTION_RETRY_ATTEMPTS; attempt++) { - try { - await twitchClient.updateRedemptionStatus(redemption, status) - return 'done' - } catch (error) { - const reason = describeError(error) - - if (isNoLongerOpen(error)) { - log.info( - `Redemption ${redemption.id} is not open any more (closed in the Twitch rewards queue, or its reward was deleted): ${status} not needed` - ) - return 'no-longer-open' - } - - if (attempt === REDEMPTION_RETRY_ATTEMPTS || !isRetryable(error)) { - log.error(`Failed to set redemption ${redemption.id} to ${status} after ${attempt} attempt(s): ${reason}`) - return 'failed' - } - - const delay = retryDelayMs * 2 ** (attempt - 1) - log.error( - `Failed to set redemption ${redemption.id} to ${status} (attempt ${attempt}/${REDEMPTION_RETRY_ATTEMPTS}): ${reason}. Retrying in ${delay}ms` - ) - await sleep(delay) - } - } - - return 'failed' -} - -async function fulfillRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient): Promise<void> { - const result = await setRedemptionStatus(redemption, twitchClient, 'FULFILLED') - - if (result === 'done') { - log.info(`Channel Points redemption fulfilled: ${redemption.id}`) - return - } - - // already closed by the streamer: nothing is left to do or to worry about - if (result === 'no-longer-open') return - - // The track was played: refunding the points now would give the viewer the song for free. - // The redemption stays UNFULFILLED for the streamer or a moderator to close in the Twitch rewards queue - log.error(`Redemption ${redemption.id} was played but could not be fulfilled, it is left for a manual decision`) -} - -async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: FailureReason): Promise<void> { - const result = await setRedemptionStatus(redemption, twitchClient, 'CANCELED') - - // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise - if (result === 'done') { - log.info(`Channel Points redemption canceled (points refunded): ${redemption.id}`) - const locale = getSettings().locale - await replyInChat(buildRedemptionRejectionMessage(redemption.userName, reason, locale)) - return - } - - // Closed by the streamer in the meantime: whatever they chose (refund or accept) is already done, and either - // message here ("refunded" or "could not be refunded") could be wrong. Nothing is said in chat - if (result === 'no-longer-open') return - - const locale = getSettings().locale - await replyInChat(buildRedemptionRefundFailedMessage(redemption.userName, locale)) +/** Starts the device-code login: the flow lives in device-auth.ts, this stays the single public entry point. */ +export function startDeviceAuthorization(): Promise<TwitchDeviceCodeResponse> { + return runDeviceAuthorization(startSockets) } export type TwitchHealth = { @@ -607,23 +177,24 @@ export type TwitchHealth = { export function getTwitchHealth(): TwitchHealth { // named fields by name: the typed answer of the panel - the list would only hide what is written return { - auth: oauth?.needsReauthorization() ? 'reauthorize' : 'ok', - eventSub: eventSubSocket.instance?.isConnected() ?? false, - chat: chatSocket.instance?.isConnected() ?? false + auth: getRuntime()?.oauth.needsReauthorization() ? 'reauthorize' : 'ok', + eventSub: eventSubConnection.instance?.isConnected() ?? false, + chat: chatConnection.instance?.isConnected() ?? false } } /** Disconnects the account. Returns how many queued redemptions were left UNFULFILLED on Twitch for the streamer to close by hand. */ export async function disconnect(): Promise<number> { - if (!oauth || !client) { + const runtime = getRuntime() + if (!runtime) { log.info('Twitch integration not initialized, nothing to disconnect') return 0 } for (const entry of managedSockets) { - if (entry.instance) { - await entry.instance.disconnect() - entry.instance = null + if (entry.state.instance) { + await entry.state.instance.disconnect() + entry.state.instance = null } } @@ -634,10 +205,10 @@ export async function disconnect(): Promise<number> { if (detached > 0) log.warn(`${detached} queued redemption(s) stay UNFULFILLED on Twitch: fulfill or refund them in the rewards queue`) // asked before the tokens are forgotten locally; a failure does not stop the disconnect - await oauth.revokeTokens() + await runtime.oauth.revokeTokens() - oauth.clearTokenData() - client.clearUserInfo() + runtime.oauth.clearTokenData() + runtime.client.clearUserInfo() clearTwitchOAuthState() log.info('Twitch account disconnected') @@ -645,10 +216,11 @@ export async function disconnect(): Promise<number> { } export async function refreshConnection(): Promise<TwitchUserInfo> { - if (!oauth || !client) throw new AppError('TWITCH_REFRESH_ERROR') + const runtime = getRuntime() + if (!runtime) throw new AppError('TWITCH_REFRESH_ERROR') try { - const userInfo = await client.getUserInfo() + const userInfo = await runtime.client.getUserInfo() updateTwitchOAuthState({ userInfo }) @@ -662,54 +234,24 @@ export async function refreshConnection(): Promise<TwitchUserInfo> { } } -// Export for internal use (routes) -export function getClient(): TwitchClient | null { - return client -} - // The panel is told this instead of a masked clone of the secrets: the routes only need the boolean export function isTwitchConnected(): boolean { - return oauth !== null && client !== null && oauth.isAuthenticated() && client.getCachedUserInfo() !== null -} - -// Export for testing purposes only -export const _test = { - hasPermission, - matchesCommand, - handleChatMessage, - resetCooldown: () => { - lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 - }, - setChat: (mock: TwitchChat | null) => { - chatSocket.instance = mock - }, - setChatWaitLimits: (ms: { start: number; reconnect: number }) => { - chatStartWaitMs = ms.start - chatReconnectWaitMs = ms.reconnect - }, - cancelRedemption, - fulfillRedemption, - handleChannelPointsRedemption, - setRetryDelay: (ms: number) => { - retryDelayMs = ms - } + const runtime = getRuntime() + return runtime !== null && runtime.oauth.isAuthenticated() && runtime.client.getCachedUserInfo() !== null } // Export for testing purposes only /** @internal exported for tests */ export async function _resetIntegration(): Promise<void> { for (const entry of managedSockets) { - if (entry.instance) await entry.instance.disconnect() - entry.instance = null - entry.starting = null + if (entry.state.instance) await entry.state.instance.disconnect() + entry.state.instance = null + entry.state.starting = null } - oauth = null - client = null - deviceAuthorizationPromise = null - deviceAuthorizationGeneration = 0 - handledRedemptions.clear() - lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 - chatStartWaitMs = CHAT_START_WAIT_MS - chatReconnectWaitMs = CHAT_RECONNECT_WAIT_MS + setRuntime(null) + resetTunables() + resetCommandCooldowns() + resetHandledRedemptions() + resetDeviceAuthorization() } diff --git a/src/integrations/twitch/redemptions.ts b/src/integrations/twitch/redemptions.ts new file mode 100644 index 0000000..7ca7423 --- /dev/null +++ b/src/integrations/twitch/redemptions.ts @@ -0,0 +1,203 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import type { TwitchClient } from './client.js' +import type { TwitchChannelPointsRedemption } from './types.js' +import { getRuntime, twitchBackoff } from './state.js' +import { replyInChat } from './chat-commands.js' +import { isRetryable } from './error.js' +import { detachChannelPointsRedemptions } from '../../queue.js' +import { requestSong } from '../../request.js' +import { + buildRedemptionAcceptedMessage, + buildRedemptionRejectionMessage, + buildRedemptionRefundFailedMessage +} from '../../chat-replies.js' +import { getTwitchConfig } from './config.js' +import { getSettings } from '../../settings.js' +import { notify } from '../../state-events.js' +import { AppError, type AddedSong, type FailureReason } from '../../types.js' +import type { QueueItem } from '../../types.js' +import { createLogger, describeError } from '../../logger.js' +import type { RedemptionOutcome } from '../../finish.js' + +// The channel points pipeline: a redemption of the configured reward buys one song request, +// which is then fulfilled or refunded with the track's playback outcome. The lifecycle in +// index.ts wires this module to EventSub (incoming redemptions) and to the queue (the +// playback outcome of a queued track). + +const log = createLogger('TWITCH') + +const REDEMPTION_RETRY_ATTEMPTS = 3 + +// the ids of the redemptions handled so far (the latest ones only) +const HANDLED_REDEMPTIONS_LIMIT = 500 +const handledRedemptions = new Set<string>() + +// what is kept with a queued track about the redemption that paid for it +export type TrackedRedemption = NonNullable<QueueItem['channelPointsRedemption']> + +const trackRedemption = (event: TwitchChannelPointsRedemption): TrackedRedemption => ({ + id: event.id, + rewardId: event.reward.id, + userName: event.user_name +}) + +/** Reacts to the playback outcome of a track that a redemption paid for: fulfill it, or refund it with the reason. */ +export function handleChannelPointsPlaybackOutcome(tracked: TrackedRedemption, outcome: RedemptionOutcome, twitchClient: TwitchClient): void { + if (outcome.status === 'failed') { + void cancelRedemption(tracked, twitchClient, outcome.reason) + return + } + + if (!getTwitchConfig().autoFulfillRedemptions) { + log.info(`Redemption ${tracked.id} finished playback, left unfulfilled (auto-fulfill disabled)`) + return + } + + void fulfillRedemption(tracked, twitchClient) +} + +/** Handles one delivered redemption: a matching, unseen request is turned into a song request. */ +export async function handleChannelPointsRedemption(event: TwitchChannelPointsRedemption, twitchClient: TwitchClient): Promise<void> { + const configuredRewardId = getTwitchConfig().channelPointsRewardId + if (!configuredRewardId || event.reward.id !== configuredRewardId) { + // the subscription sees every redemption of the channel, most are other rewards' business + log.debug(`Ignoring redemption with non-matching reward ID: ${event.reward.id} (configured: ${configuredRewardId || 'none'})`) + return + } + + // a repeated delivery of the same redemption must not take the points twice + if (handledRedemptions.has(event.id)) { + log.info(`Ignoring redemption ${event.id}: it was already handled`) + return + } + handledRedemptions.add(event.id) + if (handledRedemptions.size > HANDLED_REDEMPTIONS_LIMIT) handledRedemptions.delete(handledRedemptions.values().next().value as string) + + log.info(`Channel Points redemption: ${event.reward.title} by ${event.user_name}`) + + const tracked = trackRedemption(event) + + const query = event.user_input.trim() + if (!query) { + log.warn(`Empty song request in redemption: ${event.id}`) + await cancelRedemption(tracked, twitchClient, { code: 'SONG_NOT_FOUND' }) + return + } + + let added: AddedSong + try { + // only the request itself: past this point the track is queued and the points are spent, so a later + // failure must not refund them (the track would play for free and close a refunded reward) + added = await requestSong(query, event.user_name, { channelPointsRedemption: tracked }) + } catch (error) { + // a refusal (a duplicate, a limit, a filter) is routine: the points go back and the viewer is told why. + // Anything else is an outage: this catch is the only place its details are logged + if (error instanceof AppError) { + log.warn(`Song request failed for redemption ${event.id}: ${describeError(error)}`) + } else { + log.error(`Song request failed for redemption ${event.id}: ${describeError(error, true)}`) + } + + const reason: FailureReason = error instanceof AppError ? error : { code: 'SERVER_ERROR' } + await cancelRedemption(tracked, twitchClient, reason) + return + } + + notify({ type: 'viewerRequest', user: event.user_name, title: added.song.title }) + + // The account can be disconnected while the song is being looked up. The track is queued by now, but there is + // no login left to close the redemption with, so it is left for the streamer like the others + if (!getRuntime()?.oauth.isAuthenticated()) { + const detached = detachChannelPointsRedemptions() + log.warn(`Twitch was disconnected while redemption ${event.id} was being handled: ${detached} queued redemption(s) stay UNFULFILLED on Twitch`) + return + } + + log.info(`Redemption ${event.id} added to queue`) + const locale = getSettings().locale + await replyInChat(buildRedemptionAcceptedMessage(event.user_name, added, locale)) +} + +// Twitch answers 404 when there is no UNFULFILLED redemption with that id: the streamer already closed it in the rewards +// queue, or the reward was deleted. (A 400 means the request itself is wrong, so it stays a failure) +const isNoLongerOpen = (error: unknown): boolean => error instanceof AppError && error.params?.status === 404 + +type RedemptionUpdate = 'done' | 'no-longer-open' | 'failed' + +// Sets the status with retries and backoff. Returns whether Twitch accepted it +async function setRedemptionStatus( + redemption: TrackedRedemption, + twitchClient: TwitchClient, + status: 'FULFILLED' | 'CANCELED' +): Promise<RedemptionUpdate> { + for (let attempt = 1; attempt <= REDEMPTION_RETRY_ATTEMPTS; attempt++) { + try { + await twitchClient.updateRedemptionStatus(redemption, status) + return 'done' + } catch (error) { + const reason = describeError(error) + + if (isNoLongerOpen(error)) { + log.info( + `Redemption ${redemption.id} is not open any more (closed in the Twitch rewards queue, or its reward was deleted): ${status} not needed` + ) + return 'no-longer-open' + } + + if (attempt === REDEMPTION_RETRY_ATTEMPTS || !isRetryable(error)) { + log.error(`Failed to set redemption ${redemption.id} to ${status} after ${attempt} attempt(s): ${reason}`) + return 'failed' + } + + const delay = twitchBackoff.delayMs * 2 ** (attempt - 1) + log.error( + `Failed to set redemption ${redemption.id} to ${status} (attempt ${attempt}/${REDEMPTION_RETRY_ATTEMPTS}): ${reason}. Retrying in ${delay}ms` + ) + await sleep(delay) + } + } + + return 'failed' +} + +/** Marks a played redemption FULFILLED; one that cannot be set is left for a manual decision, never refunded. */ +export async function fulfillRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient): Promise<void> { + const result = await setRedemptionStatus(redemption, twitchClient, 'FULFILLED') + + if (result === 'done') { + log.info(`Channel Points redemption fulfilled: ${redemption.id}`) + return + } + + // already closed by the streamer: nothing is left to do or to worry about + if (result === 'no-longer-open') return + + // The track was played: refunding the points now would give the viewer the song for free. + // The redemption stays UNFULFILLED for the streamer or a moderator to close in the Twitch rewards queue + log.error(`Redemption ${redemption.id} was played but could not be fulfilled, it is left for a manual decision`) +} + +/** Refunds a redemption (CANCELED on Twitch) and tells the viewer why; a refund Twitch did not confirm is reported as failed. */ +export async function cancelRedemption(redemption: TrackedRedemption, twitchClient: TwitchClient, reason: FailureReason): Promise<void> { + const result = await setRedemptionStatus(redemption, twitchClient, 'CANCELED') + + // "points refunded" goes to chat only when Twitch confirmed it: the points stay held otherwise + if (result === 'done') { + log.info(`Channel Points redemption canceled (points refunded): ${redemption.id}`) + const locale = getSettings().locale + await replyInChat(buildRedemptionRejectionMessage(redemption.userName, reason, locale)) + return + } + + // Closed by the streamer in the meantime: whatever they chose (refund or accept) is already done, and either + // message here ("refunded" or "could not be refunded") could be wrong. Nothing is said in chat + if (result === 'no-longer-open') return + + const locale = getSettings().locale + await replyInChat(buildRedemptionRefundFailedMessage(redemption.userName, locale)) +} + +/** @internal Forgets the seen redemption ids (used by the integration reset and the tests). */ +export function resetHandledRedemptions(): void { + handledRedemptions.clear() +} diff --git a/src/integrations/twitch/state.ts b/src/integrations/twitch/state.ts new file mode 100644 index 0000000..4995036 --- /dev/null +++ b/src/integrations/twitch/state.ts @@ -0,0 +1,69 @@ +import type { TwitchOAuth } from './oauth.js' +import type { TwitchClient } from './client.js' +import type { TwitchChat } from './chat.js' +import type { TwitchEventSub } from './eventsub.js' +import type { ReconnectingSocket } from './socket.js' + +// The mutable runtime of the one Twitch account this server serves. It used to be eight +// module-level `let`s scattered over index.ts: the lifecycle (index.ts) writes it when the +// integration starts and stops, the feature modules (redemptions, chat-commands, +// device-auth) read what they need, and the tests point the fields at their fakes instead +// of going through hooks. + +// Set once by initializeTwitchIntegration, cleared by disconnect and the test reset: +// a null runtime means the integration is not initialized (or torn down) +export interface TwitchRuntime { + oauth: TwitchOAuth + client: TwitchClient +} + +let runtime: TwitchRuntime | null = null + +export function getRuntime(): TwitchRuntime | null { + return runtime +} + +export function setRuntime(next: TwitchRuntime | null): void { + runtime = next +} + +// One live socket entry: the instance and the start that may be running for it. +// The lifecycle walks these for start, stop, health and reset; chat-commands also reads +// the chat one to wait for a connection before replying +export interface SocketState<T extends ReconnectingSocket> { + instance: T | null + starting: Promise<void> | null +} + +export const eventSubConnection: SocketState<TwitchEventSub> = { instance: null, starting: null } + +// The chat reply waits out a start with one window and a reconnect with a longer one; +// tests shrink both windows instead of sleeping for the real 5 s / 60 s +export const CHAT_START_WAIT_MS = 5000 +export const CHAT_RECONNECT_WAIT_MS = 60000 + +export interface ChatConnection extends SocketState<TwitchChat> { + startWaitMs: number + reconnectWaitMs: number +} + +export const chatConnection: ChatConnection = { + instance: null, + starting: null, + startWaitMs: CHAT_START_WAIT_MS, + reconnectWaitMs: CHAT_RECONNECT_WAIT_MS +} + +// One backoff knob for the Twitch retries that have to live through tests: the redemption +// status updates and the login's getUserInfo attempts. Tests shrink it to keep the suites +// fast; the reset below restores the real pace +export const REDEMPTION_RETRY_DELAY_MS = 1000 + +export const twitchBackoff = { delayMs: REDEMPTION_RETRY_DELAY_MS } + +// Restores every tunable the tests may have shrunk (used by _resetIntegration) +export function resetTunables(): void { + chatConnection.startWaitMs = CHAT_START_WAIT_MS + chatConnection.reconnectWaitMs = CHAT_RECONNECT_WAIT_MS + twitchBackoff.delayMs = REDEMPTION_RETRY_DELAY_MS +} diff --git a/src/routes/twitch.ts b/src/routes/twitch.ts index dc7459c..95628df 100644 --- a/src/routes/twitch.ts +++ b/src/routes/twitch.ts @@ -3,18 +3,19 @@ import { ok, sendConfigUpdate } from '../http.js' import type { TwitchConnectionResponse, TwitchCreateCustomReward, TwitchUpdateCustomReward } from '../integrations/twitch/types.js' import { AppError } from '../types.js' import { parseRewardInput, toRewardDto } from '../integrations/twitch/rewards.js' -import { startDeviceAuthorization, disconnect, refreshConnection, getClient, isTwitchConnected, getTwitchHealth } from '../integrations/twitch/index.js' +import { startDeviceAuthorization, disconnect, refreshConnection, isTwitchConnected, getTwitchHealth } from '../integrations/twitch/index.js' +import { getRuntime } from '../integrations/twitch/state.js' import { getTwitchConfig, updateTwitchConfig } from '../integrations/twitch/config.js' import { isLocal, localOnly } from '../local-only.js' import { getPublicSecretsView } from '../secrets.js' import type { TwitchClient } from '../integrations/twitch/client.js' function requireClient(): TwitchClient { - const client = getClient() - if (!client || !isTwitchConnected()) { + const runtime = getRuntime() + if (!runtime || !isTwitchConnected()) { throw new AppError('TWITCH_NOT_CONNECTED') } - return client + return runtime.client } export const router = express.Router() diff --git a/tests/backend/integrations/twitch/chat-commands.test.ts b/tests/backend/integrations/twitch/chat-commands.test.ts index 0e10df8..a86aa5d 100644 --- a/tests/backend/integrations/twitch/chat-commands.test.ts +++ b/tests/backend/integrations/twitch/chat-commands.test.ts @@ -10,7 +10,9 @@ const { getConfig, updateConfig } = await import('../../../../src/config.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const { hydrateQueue } = await import('../../../../src/queue.js') const { getState, setPaused, getIsPaused } = await import('../../../../src/player.js') -const { _test, _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') +const { _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') +const { hasPermission, matchesCommand, handleChatMessage, resetCommandCooldowns } = await import('../../../../src/integrations/twitch/chat-commands.js') +const { chatConnection } = await import('../../../../src/integrations/twitch/state.js') const { clearTwitchOAuthState } = await import('../../../../src/secrets.js') import type { TwitchChatMessage } from '../../../../src/integrations/twitch/types.js' @@ -33,7 +35,8 @@ const DEFAULTS = getConfig() beforeEach(async () => { await _resetIntegration() clearTwitchOAuthState() - _test.resetCooldown() + resetCommandCooldowns() + chatConnection.instance = null hydrateQueue({ current: null, queue: [] }) setPaused(false) updateConfig({ @@ -59,49 +62,49 @@ beforeEach(async () => { test('hasPermission()', async (t) => { await t.test('"everyone" allows anyone', () => { - assert.equal(_test.hasPermission(message({ isModerator: false, isBroadcaster: false }), 'everyone'), true) + assert.equal(hasPermission(message({ isModerator: false, isBroadcaster: false }), 'everyone'), true) }) await t.test('"moderator" allows a moderator', () => { - assert.equal(_test.hasPermission(message({ isModerator: true }), 'moderator'), true) + assert.equal(hasPermission(message({ isModerator: true }), 'moderator'), true) }) await t.test('"moderator" allows the broadcaster too', () => { - assert.equal(_test.hasPermission(message({ isModerator: false, isBroadcaster: true }), 'moderator'), true) + assert.equal(hasPermission(message({ isModerator: false, isBroadcaster: true }), 'moderator'), true) }) await t.test('"moderator" denies a regular viewer', () => { - assert.equal(_test.hasPermission(message({ isModerator: false, isBroadcaster: false }), 'moderator'), false) + assert.equal(hasPermission(message({ isModerator: false, isBroadcaster: false }), 'moderator'), false) }) await t.test('"broadcaster" denies a moderator who is not the broadcaster', () => { - assert.equal(_test.hasPermission(message({ isModerator: true, isBroadcaster: false }), 'broadcaster'), false) + assert.equal(hasPermission(message({ isModerator: true, isBroadcaster: false }), 'broadcaster'), false) }) await t.test('"broadcaster" allows the broadcaster', () => { - assert.equal(_test.hasPermission(message({ isBroadcaster: true }), 'broadcaster'), true) + assert.equal(hasPermission(message({ isBroadcaster: true }), 'broadcaster'), true) }) }) test('matchesCommand()', async (t) => { await t.test('matches an exact, case-insensitive command', () => { - assert.equal(_test.matchesCommand('!SG Skip', '!sg skip'), true) + assert.equal(matchesCommand('!SG Skip', '!sg skip'), true) }) await t.test('matches when the command is followed by extra text', () => { - assert.equal(_test.matchesCommand('!sg skip please', '!sg skip'), true) + assert.equal(matchesCommand('!sg skip please', '!sg skip'), true) }) await t.test('does not match a longer word that merely starts with the command', () => { - assert.equal(_test.matchesCommand('!sg skipping', '!sg skip'), false) + assert.equal(matchesCommand('!sg skipping', '!sg skip'), false) }) await t.test('does not match an unrelated message', () => { - assert.equal(_test.matchesCommand('hello chat', '!sg skip'), false) + assert.equal(matchesCommand('hello chat', '!sg skip'), false) }) await t.test('collapses repeated whitespace before comparing', () => { - assert.equal(_test.matchesCommand('!sg skip', '!sg skip'), true) + assert.equal(matchesCommand('!sg skip', '!sg skip'), true) }) }) @@ -112,7 +115,7 @@ test('handleChatMessage() - read-only commands (now/queue)', async (t) => { }) // The command should be processed without errors - await _test.handleChatMessage(message({ text: '!sg now' })) + await handleChatMessage(message({ text: '!sg now' })) }) await t.test('"queue" command is processed for anyone', async () => { @@ -121,19 +124,19 @@ test('handleChatMessage() - read-only commands (now/queue)', async (t) => { }) // The command should be processed without errors - await _test.handleChatMessage(message({ text: '!sg queue' })) + await handleChatMessage(message({ text: '!sg queue' })) }) await t.test('a disabled command does not trigger', async () => { updateTwitchConfig({ chatCommands: { now: { enabled: false } } }) // The command should be processed but not trigger due to being disabled - await _test.handleChatMessage(message({ text: '!sg now' })) + await handleChatMessage(message({ text: '!sg now' })) }) await t.test('an unrecognized message does not trigger any command', async () => { // The message should be processed without errors - await _test.handleChatMessage(message({ text: 'just chatting' })) + await handleChatMessage(message({ text: 'just chatting' })) }) await t.test('the global cooldown blocks a second plain command from a different user', async () => { @@ -141,8 +144,8 @@ test('handleChatMessage() - read-only commands (now/queue)', async (t) => { current: { videoId: 'abc', title: 'Test Song', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' } }) - await _test.handleChatMessage(message({ text: '!sg now', displayName: 'UserOne' })) - await _test.handleChatMessage(message({ text: '!sg now', displayName: 'UserTwo' })) + await handleChatMessage(message({ text: '!sg now', displayName: 'UserOne' })) + await handleChatMessage(message({ text: '!sg now', displayName: 'UserTwo' })) // The second call should be blocked by cooldown (no error thrown, but message logged) }) @@ -154,7 +157,7 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => current: { videoId: 'abc', title: 'Song A', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' } }) - await _test.handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: false })) + await handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: false })) assert.equal(getState().current?.videoId, 'abc') }) @@ -165,7 +168,7 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => queue: [{ videoId: 'def', title: 'Song B', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' }] }) - await _test.handleChatMessage(message({ text: '!sg skip', isModerator: true })) + await handleChatMessage(message({ text: '!sg skip', isModerator: true })) assert.equal(getState().current?.videoId, 'def') }) @@ -176,13 +179,13 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => queue: [{ videoId: 'def', title: 'Song B', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' }] }) - await _test.handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: true })) + await handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: true })) assert.equal(getState().current?.videoId, 'def') }) await t.test('pause sets the player to paused', async () => { - await _test.handleChatMessage(message({ text: '!sg pause', isModerator: true })) + await handleChatMessage(message({ text: '!sg pause', isModerator: true })) assert.equal(getIsPaused(), true) }) @@ -190,25 +193,25 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => await t.test('resume clears paused', async () => { setPaused(true) - await _test.handleChatMessage(message({ text: '!sg resume', isModerator: true })) + await handleChatMessage(message({ text: '!sg resume', isModerator: true })) assert.equal(getIsPaused(), false) }) await t.test('the global cooldown blocks a second control command from a different moderator', async () => { - await _test.handleChatMessage(message({ text: '!sg pause', displayName: 'ModOne', isModerator: true })) + await handleChatMessage(message({ text: '!sg pause', displayName: 'ModOne', isModerator: true })) setPaused(false) - await _test.handleChatMessage(message({ text: '!sg pause', displayName: 'ModTwo', isModerator: true })) + await handleChatMessage(message({ text: '!sg pause', displayName: 'ModTwo', isModerator: true })) // The second call should be blocked by cooldown assert.equal(getIsPaused(), false) }) await t.test('a permission check happens before the cooldown - a denied viewer never consumes it', async () => { - await _test.handleChatMessage(message({ text: '!sg pause', isModerator: false, isBroadcaster: false })) + await handleChatMessage(message({ text: '!sg pause', isModerator: false, isBroadcaster: false })) - await _test.handleChatMessage(message({ text: '!sg pause', isModerator: true })) + await handleChatMessage(message({ text: '!sg pause', isModerator: true })) // The second call should succeed since the first didn't consume cooldown assert.equal(getIsPaused(), true) @@ -218,18 +221,19 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => test('a chat reply that waits for a connection', async (t) => { await t.test('is sent as soon as the chat reconnects, not a fixed minute later', async () => { // short limits, so a lost reply does not take a minute to give up; the real ones are 5s/60s - _test.setChatWaitLimits({ start: 100, reconnect: 300 }) + chatConnection.startWaitMs = 100 + chatConnection.reconnectWaitMs = 300 const sent: string[] = [] let connected = false - _test.setChat({ + chatConnection.instance = { isConnected: () => connected, sendMessage: (text: string) => { sent.push(text) return Promise.resolve() }, disconnect: () => Promise.resolve() - } as unknown as TwitchChat) + } as unknown as TwitchChat try { hydrateQueue({ @@ -240,29 +244,30 @@ test('a chat reply that waits for a connection', async (t) => { setTimeout(() => (connected = true), 50) const startedAt = Date.now() - await _test.handleChatMessage(message({ text: '!sg now' })) + await handleChatMessage(message({ text: '!sg now' })) const waitedMs = Date.now() - startedAt assert.equal(sent.length, 1, 'the reply went out once the chat was back') assert.ok(waitedMs < 1000, `the reply waited ${waitedMs}ms for a reconnection that took 50ms`) } finally { - _test.setChat(null) + chatConnection.instance = null } }) await t.test('is dropped, without hanging, when the chat does not come back within its window', async () => { // short limits, so the "gave up" path does not take a minute; the real ones are 5s/60s - _test.setChatWaitLimits({ start: 100, reconnect: 300 }) + chatConnection.startWaitMs = 100 + chatConnection.reconnectWaitMs = 300 const sent: string[] = [] - _test.setChat({ + chatConnection.instance = { isConnected: () => false, sendMessage: (text: string) => { sent.push(text) return Promise.resolve() }, disconnect: () => Promise.resolve() - } as unknown as TwitchChat) + } as unknown as TwitchChat try { hydrateQueue({ @@ -270,13 +275,13 @@ test('a chat reply that waits for a connection', async (t) => { }) const startedAt = Date.now() - await _test.handleChatMessage(message({ text: '!sg now' })) + await handleChatMessage(message({ text: '!sg now' })) const waitedMs = Date.now() - startedAt assert.ok(waitedMs < 2000, `the wait ended after ${waitedMs}ms, not by hanging`) assert.equal(sent.length, 0, 'no reply without a connection') } finally { - _test.setChat(null) + chatConnection.instance = null } }) }) @@ -289,10 +294,10 @@ test('handleChatMessage() - custom configured commands', async (t) => { queue: [{ videoId: 'def', title: 'Song B', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' }] }) - await _test.handleChatMessage(message({ text: '!sg skip', isModerator: true })) + await handleChatMessage(message({ text: '!sg skip', isModerator: true })) assert.equal(getState().current?.videoId, 'abc') // old command text no longer works - await _test.handleChatMessage(message({ text: '!qskip', isModerator: true })) + await handleChatMessage(message({ text: '!qskip', isModerator: true })) assert.equal(getState().current?.videoId, 'def') }) @@ -303,7 +308,7 @@ test('handleChatMessage() - custom configured commands', async (t) => { queue: [{ videoId: 'def', title: 'Song B', channelTitle: 'x', thumbnail: '', duration: 100, views: 1, url: '', requestedBy: 'someone' }] }) - await _test.handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: false })) + await handleChatMessage(message({ text: '!sg skip', isModerator: false, isBroadcaster: false })) assert.equal(getState().current?.videoId, 'def') }) }) diff --git a/tests/backend/integrations/twitch/device-auth.test.ts b/tests/backend/integrations/twitch/device-auth.test.ts index bb41fd8..46c50d7 100644 --- a/tests/backend/integrations/twitch/device-auth.test.ts +++ b/tests/backend/integrations/twitch/device-auth.test.ts @@ -6,8 +6,10 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { initializeTwitchIntegration, startDeviceAuthorization, disconnect, _test, _resetIntegration } = +const { initializeTwitchIntegration, startDeviceAuthorization, disconnect, _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') +const { handleChannelPointsRedemption } = await import('../../../../src/integrations/twitch/redemptions.js') +const { twitchBackoff } = await import('../../../../src/integrations/twitch/state.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const { clearTwitchOAuthState, getSecrets, getPublicSecretsView } = await import('../../../../src/secrets.js') const { onNotice } = await import('../../../../src/state-events.js') @@ -200,7 +202,7 @@ test('device authorization', async (t) => { test('a login whose account details cannot be fetched is rolled back and reported to the panel', async (t) => { await t.test('Twitch is told to forget the tokens, nothing stays on disk, and the panel learns why', async () => { - _test.setRetryDelay(1) + twitchBackoff.delayMs = 1 // a 403 is a refusal that a retry does not change: it must not be repeated userInfoRefusal = json({ message: 'not yours' }, 403) @@ -224,7 +226,7 @@ test('a login whose account details cannot be fetched is rolled back and reporte }) await t.test('a passing failure is retried while attempts are left, then rolled back the same way', async () => { - _test.setRetryDelay(1) + twitchBackoff.delayMs = 1 userInfoRefusal = json({ message: 'having a moment' }, 503) await startDeviceAuthorization() @@ -241,18 +243,18 @@ const event = (id: string, userInput = '') => ({ id, user_name: 'viewer', user_i test('the same redemption delivered twice is taken once', async (t) => { updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) - _test.setRetryDelay(1) + twitchBackoff.delayMs = 1 await t.test('the second delivery of one id is ignored, another id is not', async () => { const calls: string[] = [] const client = { updateRedemptionStatus: async (_redemption: unknown, status: string) => void calls.push(status) } as unknown as TwitchClient // an empty request is refunded at once, which is what makes a handled redemption visible here - await _test.handleChannelPointsRedemption(event('red-1') as never, client) - await _test.handleChannelPointsRedemption(event('red-1') as never, client) + await handleChannelPointsRedemption(event('red-1') as never, client) + await handleChannelPointsRedemption(event('red-1') as never, client) assert.deepEqual(calls, ['CANCELED']) - await _test.handleChannelPointsRedemption(event('red-2') as never, client) + await handleChannelPointsRedemption(event('red-2') as never, client) assert.deepEqual(calls, ['CANCELED', 'CANCELED']) }) }) diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index b5cee2a..7dfea48 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -8,7 +8,10 @@ const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) -const { isRetryable, _test, _resetIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') +const { _resetIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') +const { isRetryable } = await import('../../../../src/integrations/twitch/error.js') +const { cancelRedemption, fulfillRedemption, handleChannelPointsRedemption } = await import('../../../../src/integrations/twitch/redemptions.js') +const { chatConnection, twitchBackoff } = await import('../../../../src/integrations/twitch/state.js') const { buildRedemptionRejectionMessage, buildRedemptionRefundFailedMessage } = await import('../../../../src/chat-replies.js') const { AppError } = await import('../../../../src/types.js') const { updateTwitchOAuthState, updateSecrets } = await import('../../../../src/secrets.js') @@ -48,12 +51,12 @@ function fakeClient(...answers: unknown[]): TwitchClient { beforeEach(() => { sent = [] calls = [] - _test.setRetryDelay(1) - _test.setChat({ isConnected: () => true, disconnect: async () => {}, sendMessage: async (message: string) => void sent.push(message) } as unknown as TwitchChat) + twitchBackoff.delayMs = 1 + chatConnection.instance = { isConnected: () => true, disconnect: async () => {}, sendMessage: async (message: string) => void sent.push(message) } as unknown as TwitchChat }) after(async () => { - _test.setChat(null) + chatConnection.instance = null await _resetIntegration() }) @@ -86,19 +89,19 @@ test('isRetryable()', async (t) => { test('cancelRedemption()', async (t) => { await t.test('a confirmed refund says so in chat', async () => { - await _test.cancelRedemption(redemption, fakeClient('ok'), reason) + await cancelRedemption(redemption, fakeClient('ok'), reason) assert.deepEqual(sent, [refunded()]) assert.deepEqual(calls, ['CANCELED']) }) await t.test('a passing failure is repeated and then confirmed', async () => { - await _test.cancelRedemption(redemption, fakeClient(api(503), new TypeError('fetch failed'), 'ok'), reason) + await cancelRedemption(redemption, fakeClient(api(503), new TypeError('fetch failed'), 'ok'), reason) assert.equal(calls.length, 3) assert.deepEqual(sent, [refunded()]) }) await t.test('a redemption that is no longer open (404) is left alone: one try, nothing is said in chat', async () => { - await _test.cancelRedemption(redemption, fakeClient(api(404)), reason) + await cancelRedemption(redemption, fakeClient(api(404)), reason) assert.equal(calls.length, 1) assert.deepEqual(sent, []) }) @@ -107,20 +110,20 @@ test('cancelRedemption()', async (t) => { for (const status of [400, 403]) { calls = [] sent = [] - await _test.cancelRedemption(redemption, fakeClient(api(status)), reason) + await cancelRedemption(redemption, fakeClient(api(status)), reason) assert.equal(calls.length, 1, String(status)) assert.deepEqual(sent, [refundFailed()], String(status)) } }) await t.test('a login that is gone is one try, not three', async () => { - await _test.cancelRedemption(redemption, fakeClient(new AppError('TWITCH_NOT_CONNECTED', 'not connected')), reason) + await cancelRedemption(redemption, fakeClient(new AppError('TWITCH_NOT_CONNECTED', 'not connected')), reason) assert.equal(calls.length, 1) assert.deepEqual(sent, [refundFailed()]) }) await t.test('after three failed tries the refund is reported as failed, never as done', async () => { - await _test.cancelRedemption(redemption, fakeClient(api(503)), reason) + await cancelRedemption(redemption, fakeClient(api(503)), reason) assert.equal(calls.length, 3) assert.deepEqual(sent, [refundFailed()]) }) @@ -128,12 +131,12 @@ test('cancelRedemption()', async (t) => { test('fulfillRedemption()', async (t) => { await t.test('a redemption that is no longer open is one try and no error', async () => { - await _test.fulfillRedemption(redemption, fakeClient(api(404))) + await fulfillRedemption(redemption, fakeClient(api(404))) assert.deepEqual(calls, ['FULFILLED']) }) await t.test('when it cannot be fulfilled it is left open, not canceled (the song was played)', async () => { - await _test.fulfillRedemption(redemption, fakeClient(api(503))) + await fulfillRedemption(redemption, fakeClient(api(503))) assert.deepEqual(calls, ['FULFILLED', 'FULFILLED', 'FULFILLED']) assert.deepEqual(sent, []) }) @@ -174,19 +177,19 @@ test('handleChannelPointsRedemption()', async (t) => { t.beforeEach(() => { initializeTwitchIntegration({ clientId: 'client-1' }) updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) - _test.setRetryDelay(1) + twitchBackoff.delayMs = 1 calls = [] }) t.after(() => { - _test.setChat(null) + chatConnection.instance = null }) await t.test('a redemption of another reward is ignored at debug level and not touched', async (t2) => { const debug = t2.mock.method(console, 'log', () => {}) const errors = t2.mock.method(console, 'error', () => {}) - await _test.handleChannelPointsRedemption({ ...event('red-other', 'song'), reward: { id: 'another-reward', title: 'Other' } } as never, fakeClient('ok')) + await handleChannelPointsRedemption({ ...event('red-other', 'song'), reward: { id: 'another-reward', title: 'Other' } } as never, fakeClient('ok')) assert.deepEqual(calls, [], 'no status update for a foreign reward') assert.equal(debug.mock.callCount(), 0, 'a debug line is below the default info level and does not show') @@ -199,7 +202,7 @@ test('handleChannelPointsRedemption()', async (t) => { const A = 'aaaaaaaaaaa' blockTrack(A, 'blocked') - await _test.handleChannelPointsRedemption(event('red-block', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')) + await handleChannelPointsRedemption(event('red-block', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')) assert.deepEqual(calls, ['CANCELED']) assert.equal(warnings.mock.callCount(), 1) @@ -222,7 +225,7 @@ test('handleChannelPointsRedemption()', async (t) => { }) as typeof fetch try { - await _test.handleChannelPointsRedemption(event('red-broken', 'some song') as never, fakeClient('ok')) + await handleChannelPointsRedemption(event('red-broken', 'some song') as never, fakeClient('ok')) } finally { globalThis.fetch = realFetch } @@ -243,13 +246,13 @@ test('handleChannelPointsRedemption()', async (t) => { queue.setCurrent(null) // the chat connection breaks while the acceptance reply is being sent: the song is already playing - _test.setChat({ + chatConnection.instance = { isConnected: () => { throw new Error('chat blew up') }, disconnect: async () => {}, sendMessage: async () => {} - } as unknown as TwitchChat) + } as unknown as TwitchChat const A = 'aaaaaaaaaaa' globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -270,12 +273,12 @@ test('handleChannelPointsRedemption()', async (t) => { try { // the broken chat must escape the handler instead of being mistaken for a failed request await assert.rejects( - _test.handleChannelPointsRedemption(event('red-late', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')), + handleChannelPointsRedemption(event('red-late', `https://www.youtube.com/watch?v=${A}`) as never, fakeClient('ok')), /chat blew up/ ) } finally { globalThis.fetch = realFetch - _test.setChat(null) + chatConnection.instance = null await _resetIntegration() updateTwitchOAuthState({ tokenData: null }) } From d1b84ec6db66b218aa39bc6a74531ca978e3db4a Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:03:29 +0700 Subject: [PATCH 21/22] Improve Twitch lifecycle handling and track display Twitch: - centralize chat replies and per-initialization tunables - stop background connections before saving state during shutdown - validate reward updates through the shared schema - record actual recovery durations in outage journal entries UI: - show localized placeholders for tracks without titles --- public/js/control/views/activity.js | 4 +- src/activity.ts | 15 +- src/chat-replies.ts | 4 +- src/integrations/twitch/chat-commands.ts | 47 +----- src/integrations/twitch/chat-reply.ts | 45 ++++++ src/integrations/twitch/config.ts | 6 +- src/integrations/twitch/device-auth.ts | 6 +- src/integrations/twitch/eventsub.ts | 26 +++- src/integrations/twitch/index.ts | 20 ++- src/integrations/twitch/redemptions.ts | 12 +- src/integrations/twitch/rewards.ts | 138 +++++++++++------- src/integrations/twitch/state.ts | 51 ++++--- src/server.ts | 9 +- src/shutdown.ts | 12 ++ tests/backend/chat-replies.test.ts | 5 + .../integrations/twitch/chat-commands.test.ts | 25 ++-- .../integrations/twitch/config.test.ts | 87 ++++++----- .../integrations/twitch/device-auth.test.ts | 14 +- .../integrations/twitch/eventsub.test.ts | 30 ++-- .../integrations/twitch/redemptions.test.ts | 23 +-- .../integrations/twitch/twitch.test.ts | 4 +- tests/frontend/control/activity.test.js | 14 ++ 22 files changed, 364 insertions(+), 233 deletions(-) create mode 100644 src/integrations/twitch/chat-reply.ts diff --git a/public/js/control/views/activity.js b/public/js/control/views/activity.js index c14cb46..5806d8e 100644 --- a/public/js/control/views/activity.js +++ b/public/js/control/views/activity.js @@ -1,4 +1,4 @@ -import { escapeHtml, translateErrorCode, youtubeThumbnail } from '../../shared.js' +import { displayTitle, escapeHtml, translateErrorCode, youtubeThumbnail } from '../../shared.js' import { t } from '../../i18n.js' import { dom } from '../dom.js' import { formatDateTime } from '../ui.js' @@ -18,7 +18,7 @@ export const activityView = createListView(dom.activityListWrapper, { ].join('|') }, renderRow: (entry) => { - const title = entry.title || entry.query + const title = entry.title != null ? displayTitle(entry.title) : entry.query const status = STATUS_LABEL_KEYS[entry.status] ?? STATUS_LABEL_KEYS.rejected return row({ diff --git a/src/activity.ts b/src/activity.ts index f3d9c68..fc39f6d 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -31,11 +31,22 @@ const entries = createListStore<ActivityEntry>({ topics: ['activity'] }) -export function logActivity(entry: Omit<ActivityEntry, 'at'>): void { +/** Appends one line to the journal. Returns the moment of the entry: the key it can be found by again (the outage line of the Twitch integration corrects itself with it). */ +export function logActivity(entry: Omit<ActivityEntry, 'at'>): number { + const at = Date.now() entries.update((list) => { - list.unshift({ ...entry, at: Date.now() }) + list.unshift({ ...entry, at }) if (list.length > ACTIVITY_LIMIT) list.length = ACTIVITY_LIMIT }) + return at +} + +/** Reworks the reasonParams of one line: the outage entry states its minutes while the outage is still going on, and learns the real length when the connection returns. */ +export function updateActivityReasonParams(at: number, reasonParams: ReasonParams): void { + entries.update((list) => { + const entry = list.find((entry) => entry.at === at) + if (entry) entry.reasonParams = { ...entry.reasonParams, ...reasonParams } + }) } export function getActivity(): ActivityEntry[] { diff --git a/src/chat-replies.ts b/src/chat-replies.ts index 09a571f..2d1e97a 100644 --- a/src/chat-replies.ts +++ b/src/chat-replies.ts @@ -12,8 +12,10 @@ export function buildNowPlayingMessage(state: PlayerState, locale: string): stri return t(locale, 'chat.nothingPlaying') } + // a track can come without a title (YouTube gave none): the chat sees the localized placeholder, not a blank gap + const title = state.current.title || t(locale, 'common.unknownTitle') const requester = state.current.isFallback ? t(locale, 'fallback.requester') : state.current.requestedBy - const base = t(locale, 'chat.nowPlayingWithRequester', { title: state.current.title, requestedBy: requester }) + const base = t(locale, 'chat.nowPlayingWithRequester', { title, requestedBy: requester }) const pausedSuffix = state.isPaused ? t(locale, 'chat.pausedSuffix') : '' return base + pausedSuffix } diff --git a/src/integrations/twitch/chat-commands.ts b/src/integrations/twitch/chat-commands.ts index 777d38a..bdb9df0 100644 --- a/src/integrations/twitch/chat-commands.ts +++ b/src/integrations/twitch/chat-commands.ts @@ -1,6 +1,5 @@ -import { setTimeout as sleep } from 'node:timers/promises' import type { TwitchChatMessage, TwitchChatPermission } from './types.js' -import { chatConnection } from './state.js' +import { replyInChat } from './chat-reply.js' import { buildNowPlayingMessage, buildQueueMessage, @@ -10,11 +9,11 @@ import { getTwitchConfig, CHAT_COMMAND_KEYS } from './config.js' import { getState, skipCurrent, setPaused } from '../../player.js' import { getSettings } from '../../settings.js' import { t } from '../../i18n.js' -import { createLogger, describeError } from '../../logger.js' +import { createLogger } from '../../logger.js' -// Chat commands of the queue: matching, permissions, cooldowns and the replies. The chat -// socket itself belongs to the lifecycle in index.ts; this module reads its connection -// state (from state.ts) to wait for it before answering. +// Chat commands of the queue: matching, permissions, cooldowns and the replies. The chat socket +// belongs to the lifecycle in index.ts, and sending through it to chat-reply.ts; this module +// only decides what a message triggers and what the answer says. const chatLog = createLogger('TWITCH CHAT') @@ -39,40 +38,6 @@ export function matchesCommand(text: string, command: string): boolean { return normalized === command || normalized.startsWith(`${command} `) } -const CHAT_CONNECT_POLL_MS = 250 - -async function waitForChat(limitMs: number): Promise<boolean> { - const deadline = Date.now() + limitMs - - while (true) { - const chat = chatConnection.instance - if (!chat) return false - if (chat.isConnected()) return true - - const remaining = deadline - Date.now() - if (remaining <= 0) return false - await sleep(Math.min(CHAT_CONNECT_POLL_MS, remaining)) - } -} - -/** Sends a message through the chat socket of the connected account, waiting out a start or a reconnect. */ -export async function replyInChat(message: string): Promise<void> { - const chat = chatConnection.instance - if (!chat) return - - if (!chat.isConnected()) { - const connected = await waitForChat(chatConnection.starting ? chatConnection.startWaitMs : chatConnection.reconnectWaitMs) - if (!connected) { - chatLog.warn(`Chat not connected, skipping reply: ${message}`) - return - } - } - - await chat.sendMessage(message).catch((error) => { - chatLog.error(`Failed to send chat reply: ${describeError(error)}`) - }) -} - // One global (not per-user) cooldown per kind of command: two moderators can't double up on skip/pause/resume, // and now/queue can't be spammed const lastRun = { plainCooldownSeconds: 0, controlCooldownSeconds: 0 } @@ -139,7 +104,7 @@ export async function handleChatMessage(message: TwitchChatMessage): Promise<voi } } -/** @internal Clears the command cooldowns (used by the integration reset and the tests). */ +/** Clears the command cooldowns: stop() resets them when the integration is torn down. */ export function resetCommandCooldowns(): void { lastRun.plainCooldownSeconds = lastRun.controlCooldownSeconds = 0 } diff --git a/src/integrations/twitch/chat-reply.ts b/src/integrations/twitch/chat-reply.ts new file mode 100644 index 0000000..a75eafc --- /dev/null +++ b/src/integrations/twitch/chat-reply.ts @@ -0,0 +1,45 @@ +import { setTimeout as sleep } from 'node:timers/promises' +import { chatConnection, getTunables } from './state.js' +import { createLogger, describeError } from '../../logger.js' + +// The one sender into the Twitch chat. The replies of the commands and the messages of the +// redemption pipeline (accepted, refunded) go through it alike, so neither module depends on +// the other for that. The socket itself belongs to the lifecycle in index.ts; this module reads +// its connection state (from state.ts) to wait for it before sending. + +const chatLog = createLogger('TWITCH CHAT') + +const CHAT_CONNECT_POLL_MS = 250 + +async function waitForChat(limitMs: number): Promise<boolean> { + const deadline = Date.now() + limitMs + + while (true) { + const chat = chatConnection.instance + if (!chat) return false + if (chat.isConnected()) return true + + const remaining = deadline - Date.now() + if (remaining <= 0) return false + await sleep(Math.min(CHAT_CONNECT_POLL_MS, remaining)) + } +} + +/** Sends a message through the chat socket of the connected account, waiting out a start or a reconnect. */ +export async function replyInChat(message: string): Promise<void> { + const chat = chatConnection.instance + if (!chat) return + + if (!chat.isConnected()) { + const { chatStartWaitMs, chatReconnectWaitMs } = getTunables() + const connected = await waitForChat(chatConnection.starting ? chatStartWaitMs : chatReconnectWaitMs) + if (!connected) { + chatLog.warn(`Chat not connected, skipping reply: ${message}`) + return + } + } + + await chat.sendMessage(message).catch((error) => { + chatLog.error(`Failed to send chat reply: ${describeError(error)}`) + }) +} diff --git a/src/integrations/twitch/config.ts b/src/integrations/twitch/config.ts index ce4591e..e85a8e8 100644 --- a/src/integrations/twitch/config.ts +++ b/src/integrations/twitch/config.ts @@ -18,12 +18,10 @@ const commandSchema: Schema = { permission: rules.oneOf(PERMISSIONS) } -// `validateTwitchConfigUpdates` and `restoreTwitchConfig` are exported for tests +// The routes read and update through the same module; the tests drive the update path the routes take export const { getConfig: getTwitchConfig, - updateConfig: updateTwitchConfig, - validateConfigUpdates: validateTwitchConfigUpdates, - restoreConfig: restoreTwitchConfig + updateConfig: updateTwitchConfig } = createConfigModule<TwitchConfig>({ filePath: () => dataPath('twitch-config.json'), defaults: { diff --git a/src/integrations/twitch/device-auth.ts b/src/integrations/twitch/device-auth.ts index 3fec7a2..5c64e73 100644 --- a/src/integrations/twitch/device-auth.ts +++ b/src/integrations/twitch/device-auth.ts @@ -1,6 +1,6 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { TwitchDeviceCodeResponse, TwitchUserInfo } from './types.js' -import { getRuntime, twitchBackoff } from './state.js' +import { getRuntime, getTunables } from './state.js' import { isRetryable } from './error.js' import { clearTwitchAuthorization, clearTwitchOAuthState, getPublicSecretsView, updateTwitchOAuthState } from '../../secrets.js' import { notify } from '../../state-events.js' @@ -80,7 +80,7 @@ export async function runDeviceAuthorization(startSockets: () => Promise<void>): // a refusal (4xx) or a refused token gives the same answer every time: repeating it only delays the rollback if (attempt === USER_INFO_RETRY_ATTEMPTS || !isRetryable(error)) break log.warn(`getUserInfo attempt ${attempt} failed, retrying: ${describeError(error)}`) - await sleep(twitchBackoff.delayMs * 2 ** (attempt - 1)) + await sleep(getTunables().retryDelayMs * 2 ** (attempt - 1)) } } @@ -139,7 +139,7 @@ export async function runDeviceAuthorization(startSockets: () => Promise<void>): return device } -/** @internal Forgets a waiting login (used by the integration reset). */ +/** Forgets a waiting login: stop() calls it when the integration is torn down (the tokens are not touched). */ export function resetDeviceAuthorization(): void { deviceAuthorizationPromise = null deviceAuthorizationGeneration = 0 diff --git a/src/integrations/twitch/eventsub.ts b/src/integrations/twitch/eventsub.ts index a3c1216..c6ac4c9 100644 --- a/src/integrations/twitch/eventsub.ts +++ b/src/integrations/twitch/eventsub.ts @@ -3,7 +3,7 @@ import { CHANNEL_POINTS_REDEMPTION, type TwitchClient } from './client.js' import { ReconnectingSocket, closeQuietly } from './socket.js' import type { EventSubMessage, TwitchChannelPointsRedemption } from './types.js' import { createLogger, describeError } from '../../logger.js' -import { logActivity } from '../../activity.js' +import { logActivity, updateActivityReasonParams } from '../../activity.js' import { normalizeTwitchError } from './error.js' const log = createLogger('TWITCH EVENTSUB') @@ -23,11 +23,13 @@ export class TwitchEventSub extends ReconnectingSocket { private static readonly MAX_RECENT_MESSAGE_IDS = 100 // A dropped session loses whatever Twitch sent while it was down (a laptop asleep, a changed network), and - // the streamer can only learn it from the journal. A real outage leaves a line there; a blip (and Twitch's - // own seamless reconnect request) stays below the threshold and out of the journal + // the streamer can only learn it from the journal. A real outage leaves a line there as soon as it crosses the + // threshold - with the minutes known at that moment - and the line learns the real length when the connection + // returns, still as one line; a blip (and Twitch's own seamless reconnect request) stays out of the journal private hadReadySession = false private connectionLostAt: number | null = null private journalTimer: ReturnType<typeof setTimeout> | null = null + private journalEntryAt: number | null = null protected journalAfterMs = 60_000 protected markReady(): void { @@ -36,12 +38,17 @@ export class TwitchEventSub extends ReconnectingSocket { } protected onOpen(_socket: WebSocket): void { - // the connection is back: whatever outage it ended, the alarm is not needed anymore. A line in the journal - // was already written by the timer if the outage crossed the threshold, so nothing is written here + // the connection is back: whatever outage it ended, the alarm is not needed anymore. The line the threshold + // already wrote says how long the outage was at that moment; the recovery knows the real length and + // corrects that same line instead of writing a second one if (this.journalTimer) { clearTimeout(this.journalTimer) this.journalTimer = null } + if (this.connectionLostAt !== null && this.journalEntryAt !== null) { + updateActivityReasonParams(this.journalEntryAt, { minutes: Math.round((Date.now() - this.connectionLostAt) / 60_000) }) + } + this.journalEntryAt = null this.connectionLostAt = null } @@ -56,11 +63,12 @@ export class TwitchEventSub extends ReconnectingSocket { this.connectionLostAt = lostAt // the journal line is written when the outage crosses the threshold, not when it ends: a long one must be - // visible even if the connection never comes back. A blip cancels the timer in onOpen and stays unrecorded + // visible even if the connection never comes back, and its minutes are the ones known at that moment - + // a blip cancels the timer in onOpen and stays unrecorded if (this.journalTimer) clearTimeout(this.journalTimer) this.journalTimer = setTimeout(() => { this.journalTimer = null - logActivity({ + this.journalEntryAt = logActivity({ requestedBy: 'Twitch', query: 'Twitch', title: null, @@ -73,11 +81,13 @@ export class TwitchEventSub extends ReconnectingSocket { } async disconnect(): Promise<void> { - // an intentional stop is not an outage: whatever alarm survived from it is cancelled here + // an intentional stop is not an outage: whatever alarm survived from it is cancelled here, together with + // the correction a later recovery would have written if (this.journalTimer) { clearTimeout(this.journalTimer) this.journalTimer = null } + this.journalEntryAt = null this.connectionLostAt = null await super.disconnect() } diff --git a/src/integrations/twitch/index.ts b/src/integrations/twitch/index.ts index 0903062..23301e1 100644 --- a/src/integrations/twitch/index.ts +++ b/src/integrations/twitch/index.ts @@ -19,7 +19,8 @@ import { setRuntime, eventSubConnection, chatConnection, - resetTunables, + applyTunables, + type TwitchTunables, type SocketState } from './state.js' import { handleChatMessage, resetCommandCooldowns } from './chat-commands.js' @@ -86,7 +87,7 @@ const chatSocket: ManagedSocket<TwitchChat> = { const managedSockets: ManagedSocket<ReconnectingSocket>[] = [eventSubSocket, chatSocket] -export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = {}): void { +export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = {}, options: Partial<TwitchTunables> = {}): void { if (getRuntime()) { log.info('Twitch integration already initialized') return @@ -97,6 +98,10 @@ export function initializeTwitchIntegration(config: Partial<TwitchAuthConfig> = return } + // the waits and retries of this integration instance come from the options (the defaults are the real pace); + // this is the only place they are written + applyTunables(options) + const oauth = new TwitchOAuth({ clientId: config.clientId, scopes: config.scopes, @@ -240,9 +245,13 @@ export function isTwitchConnected(): boolean { return runtime !== null && runtime.oauth.isAuthenticated() && runtime.client.getCachedUserInfo() !== null } -// Export for testing purposes only -/** @internal exported for tests */ -export async function _resetIntegration(): Promise<void> { +/** + * Tears the integration down without touching the account: both sockets are closed, the runtime is dropped and + * the per-run memories (command cooldowns, seen redemptions, a waiting device-code login) are cleared. The + * counterpart of initializeTwitchIntegration: the server shutdown calls it before saving, so nothing changes + * state while it is being written. The account itself is only disconnected by disconnect(), which revokes. + */ +export async function stop(): Promise<void> { for (const entry of managedSockets) { if (entry.state.instance) await entry.state.instance.disconnect() entry.state.instance = null @@ -250,7 +259,6 @@ export async function _resetIntegration(): Promise<void> { } setRuntime(null) - resetTunables() resetCommandCooldowns() resetHandledRedemptions() resetDeviceAuthorization() diff --git a/src/integrations/twitch/redemptions.ts b/src/integrations/twitch/redemptions.ts index 7ca7423..0af6bc4 100644 --- a/src/integrations/twitch/redemptions.ts +++ b/src/integrations/twitch/redemptions.ts @@ -1,8 +1,8 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { TwitchClient } from './client.js' import type { TwitchChannelPointsRedemption } from './types.js' -import { getRuntime, twitchBackoff } from './state.js' -import { replyInChat } from './chat-commands.js' +import { getRuntime, getTunables } from './state.js' +import { replyInChat } from './chat-reply.js' import { isRetryable } from './error.js' import { detachChannelPointsRedemptions } from '../../queue.js' import { requestSong } from '../../request.js' @@ -149,8 +149,10 @@ async function setRedemptionStatus( return 'failed' } - const delay = twitchBackoff.delayMs * 2 ** (attempt - 1) - log.error( + const delay = getTunables().retryDelayMs * 2 ** (attempt - 1) + // an intermediate failure of a retryable kind is an expected hiccup on the way, not an error; + // the final failure below is the one that is an error + log.warn( `Failed to set redemption ${redemption.id} to ${status} (attempt ${attempt}/${REDEMPTION_RETRY_ATTEMPTS}): ${reason}. Retrying in ${delay}ms` ) await sleep(delay) @@ -197,7 +199,7 @@ export async function cancelRedemption(redemption: TrackedRedemption, twitchClie await replyInChat(buildRedemptionRefundFailedMessage(redemption.userName, locale)) } -/** @internal Forgets the seen redemption ids (used by the integration reset and the tests). */ +/** Forgets the seen redemption ids: stop() clears them when the integration is torn down. */ export function resetHandledRedemptions(): void { handledRedemptions.clear() } diff --git a/src/integrations/twitch/rewards.ts b/src/integrations/twitch/rewards.ts index ec33a61..3399e78 100644 --- a/src/integrations/twitch/rewards.ts +++ b/src/integrations/twitch/rewards.ts @@ -1,6 +1,7 @@ import type { TwitchCreateCustomReward, TwitchCustomReward, TwitchUpdateCustomReward } from './types.js' import { isPlain } from '../../persist.js' import { INPUT_LIMITS } from '../../validate.js' +import { field, rules, validateUpdates, type Schema } from '../../config-helper.js' // What the panel sends and what it gets back is the same object, so a form can be filled with a reward and sent as it is // (public/js/control/form.js: the name of an input is the path of its value in this object). @@ -52,73 +53,108 @@ export function toRewardDto(reward: TwitchCustomReward): RewardDto { } } -const isPositiveInteger = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 1 +// The RewardDto fields as the one schema the config-helper machinery already runs: what survives is safe to +// send to Twitch. The limits group is built from the LIMITS table, so the caps live in one place. +const limitSchema = (cap: number): Schema => ({ enabled: rules.boolean, value: rules.integer(1, cap) }) + +const rewardInputSchema: Schema = { + title: field({ + normalize: (v) => (typeof v === 'string' ? v.trim() : v), + validate: (v) => typeof v === 'string' && v.length > 0 && v.length <= TITLE_MAX_LENGTH + }), + cost: rules.integer(1, COST_MAX), + prompt: field({ + normalize: (v) => (typeof v === 'string' ? v.trim() : v), + validate: (v) => typeof v === 'string' && v.length <= PROMPT_MAX_LENGTH + }), + // an empty colour means "no change of the colour" and stays what it is; anything else has to be six hex + // digits, with or without the '#', and is normalized to the bare uppercase digits + backgroundColor: field({ + normalize: (v) => { + if (typeof v !== 'string') return v + const trimmed = v.trim() + const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed)?.[1] + return hex ? hex.toUpperCase() : trimmed + }, + validate: (v) => typeof v === 'string' && (v === '' || /^[0-9A-F]{6}$/.test(v)) + }), + isEnabled: rules.boolean, + limits: Object.fromEntries(LIMITS.map(({ name, cap }) => [name, limitSchema(cap)])) +} /** - * Turns what the panel sent into the body for Twitch, and says which fields are wrong: all of them at once, by their name in the - * RewardDto ("title", "limits.maxPerStream.value"), so the panel can mark every one. A field that is absent is left out - * (an update changes only what it names); `requireTitleAndCost` is for a new reward. + * Puts the limits in the shape their coupled rules can be expressed in: the number of a limit counts only + * while its switch is on (junk behind a switch that is off is not looked at at all), and a limit that is + * not an object, or has no boolean switch, is put behind the name of its switch, where the schema refuses it. */ -export function parseRewardInput( - body: unknown, - { requireTitleAndCost }: { requireTitleAndCost: boolean } -): { payload: TwitchCreateCustomReward | TwitchUpdateCustomReward; rejected: string[] } { - const input = isPlain(body) ? body : {} - const payload: Record<string, string | number | boolean> = {} - const rejected: string[] = [] - const reject = (name: string) => rejected.push(name) +function prepareLimits(raw: unknown): Record<string, unknown> | unknown { + if (!isPlain(raw)) return raw - if (input.title !== undefined || requireTitleAndCost) { - const title = typeof input.title === 'string' ? input.title.trim() : '' - if (title && title.length <= TITLE_MAX_LENGTH) payload.title = title - else reject('title') - } + const prepared: Record<string, unknown> = {} + for (const { name } of LIMITS) { + const limit = raw[name] + if (limit === undefined) continue - if (input.cost !== undefined || requireTitleAndCost) { - if (isPositiveInteger(input.cost) && input.cost <= COST_MAX) payload.cost = input.cost - else reject('cost') + if (!isPlain(limit) || typeof limit.enabled !== 'boolean') { + prepared[name] = isPlain(limit) ? { ...limit, enabled: limit.enabled } : { enabled: limit } + continue + } + + // the value is only looked at with the switch on - and has to be there then + prepared[name] = limit.enabled ? { ...limit, value: limit.value } : { enabled: false } } + return prepared +} - if (input.prompt !== undefined) { - const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : null +// The small camelCase to Twitch snake_case step: what the schema accepted is renamed and packed for Helix. +// An empty prompt clears the one of an existing reward and is nothing at all for a new one; a limit's number +// is packed only with its switch on. +function toRewardPayload(clean: Record<string, unknown>, requireTitleAndCost: boolean): TwitchUpdateCustomReward { + const payload: Record<string, string | number | boolean> = {} - if (prompt === null || prompt.length > PROMPT_MAX_LENGTH) reject('prompt') - // an empty prompt clears the one of an existing reward, and is nothing at all for a new one - else if (prompt || !requireTitleAndCost) payload.prompt = prompt - } + if (typeof clean.title === 'string') payload.title = clean.title + if (typeof clean.cost === 'number') payload.cost = clean.cost + if (typeof clean.prompt === 'string' && (clean.prompt !== '' || !requireTitleAndCost)) payload.prompt = clean.prompt + if (typeof clean.backgroundColor === 'string' && clean.backgroundColor !== '') payload.background_color = `#${clean.backgroundColor}` + if (typeof clean.isEnabled === 'boolean') payload.is_enabled = clean.isEnabled - if (input.backgroundColor !== undefined) { - const color = typeof input.backgroundColor === 'string' ? input.backgroundColor.trim() : null - const hex = color === null ? null : /^#?([0-9a-f]{6})$/i.exec(color)?.[1] + if (isPlain(clean.limits)) { + for (const { name, flag, value: valueKey } of LIMITS) { + const limit = clean.limits[name] + if (!isPlain(limit) || typeof limit.enabled !== 'boolean') continue - if (color === null || (color && !hex)) reject('backgroundColor') - else if (hex) payload.background_color = `#${hex.toUpperCase()}` + payload[flag] = limit.enabled + if (limit.enabled && typeof limit.value === 'number') payload[valueKey] = limit.value + } } - if (input.isEnabled !== undefined) { - if (typeof input.isEnabled === 'boolean') payload.is_enabled = input.isEnabled - else reject('isEnabled') - } + return payload +} - if (input.limits !== undefined && !isPlain(input.limits)) reject('limits') - const limits = isPlain(input.limits) ? input.limits : {} +/** + * Turns what the panel sent into the body for Twitch, and says which fields are wrong: all of them at once, by their name in the + * RewardDto ("title", "limits.maxPerStream.value"), so the panel can mark every one. A field that is absent is left out + * (an update changes only what it names); `requireTitleAndCost` is for a new reward. + */ +export function parseRewardInput( + body: unknown, + { requireTitleAndCost }: { requireTitleAndCost: boolean } +): { payload: TwitchCreateCustomReward | TwitchUpdateCustomReward; rejected: string[] } { + // a body that is not an object can name nothing at all: a new reward is told to be missing its two + // required fields, an update simply changes nothing + if (!isPlain(body)) { + return { payload: {}, rejected: requireTitleAndCost ? ['title', 'cost'] : [] } + } - for (const { name, flag, value, cap } of LIMITS) { - const limit = limits[name] - if (limit === undefined) continue + const prepared = body.limits === undefined ? body : { ...body, limits: prepareLimits(body.limits) } + const { clean, rejected } = validateUpdates(rewardInputSchema, prepared) - if (!isPlain(limit) || typeof limit.enabled !== 'boolean') { - reject(`limits.${name}.enabled`) - continue + // a new reward needs a title and a cost however valid the rest of the body is + if (requireTitleAndCost) { + for (const name of ['title', 'cost'] as const) { + if (!(name in clean) && !rejected.includes(name)) rejected.push(name) } - - payload[flag] = limit.enabled - - // the number counts only while its switch is on, and has to be a whole number within its cap then - if (!limit.enabled) continue - if (isPositiveInteger(limit.value) && limit.value <= cap) payload[value] = limit.value - else reject(`limits.${name}.value`) } - return { payload, rejected } + return { payload: toRewardPayload(clean, requireTitleAndCost), rejected } } diff --git a/src/integrations/twitch/state.ts b/src/integrations/twitch/state.ts index 4995036..9304f3a 100644 --- a/src/integrations/twitch/state.ts +++ b/src/integrations/twitch/state.ts @@ -10,7 +10,7 @@ import type { ReconnectingSocket } from './socket.js' // device-auth) read what they need, and the tests point the fields at their fakes instead // of going through hooks. -// Set once by initializeTwitchIntegration, cleared by disconnect and the test reset: +// Set once by initializeTwitchIntegration, cleared by disconnect and stop(): // a null runtime means the integration is not initialized (or torn down) export interface TwitchRuntime { oauth: TwitchOAuth @@ -28,42 +28,41 @@ export function setRuntime(next: TwitchRuntime | null): void { } // One live socket entry: the instance and the start that may be running for it. -// The lifecycle walks these for start, stop, health and reset; chat-commands also reads -// the chat one to wait for a connection before replying +// The lifecycle walks these for start, stop, health and reset; chat-reply also reads +// the chat one to wait for a connection before sending export interface SocketState<T extends ReconnectingSocket> { instance: T | null starting: Promise<void> | null } export const eventSubConnection: SocketState<TwitchEventSub> = { instance: null, starting: null } +export const chatConnection: SocketState<TwitchChat> = { instance: null, starting: null } -// The chat reply waits out a start with one window and a reconnect with a longer one; -// tests shrink both windows instead of sleeping for the real 5 s / 60 s -export const CHAT_START_WAIT_MS = 5000 -export const CHAT_RECONNECT_WAIT_MS = 60000 - -export interface ChatConnection extends SocketState<TwitchChat> { - startWaitMs: number - reconnectWaitMs: number +// The waits and pauses of a running integration. initializeTwitchIntegration is the only writer: it takes +// them from its options (the tests pass small values there instead of sleeping through the real windows), +// and whoever has to wait or retry reads the current values. +export interface TwitchTunables { + /** how long a chat reply waits out a start that is still running before it gives up on the message */ + chatStartWaitMs: number + /** how long a chat reply waits out a reconnect of a once-connected chat before it gives up on the message */ + chatReconnectWaitMs: number + /** the base delay of the Twitch retries: redemption status updates and the login's getUserInfo attempts */ + retryDelayMs: number } -export const chatConnection: ChatConnection = { - instance: null, - starting: null, - startWaitMs: CHAT_START_WAIT_MS, - reconnectWaitMs: CHAT_RECONNECT_WAIT_MS +const DEFAULT_TUNABLES: TwitchTunables = { + chatStartWaitMs: 5000, + chatReconnectWaitMs: 60000, + retryDelayMs: 1000 } -// One backoff knob for the Twitch retries that have to live through tests: the redemption -// status updates and the login's getUserInfo attempts. Tests shrink it to keep the suites -// fast; the reset below restores the real pace -export const REDEMPTION_RETRY_DELAY_MS = 1000 +let tunables: TwitchTunables = { ...DEFAULT_TUNABLES } -export const twitchBackoff = { delayMs: REDEMPTION_RETRY_DELAY_MS } +export function getTunables(): TwitchTunables { + return tunables +} -// Restores every tunable the tests may have shrunk (used by _resetIntegration) -export function resetTunables(): void { - chatConnection.startWaitMs = CHAT_START_WAIT_MS - chatConnection.reconnectWaitMs = CHAT_RECONNECT_WAIT_MS - twitchBackoff.delayMs = REDEMPTION_RETRY_DELAY_MS +/** Applies the options of one initialization over a fresh copy of the defaults: the single write of them. */ +export function applyTunables(options: Partial<TwitchTunables> = {}): void { + tunables = { ...DEFAULT_TUNABLES, ...options } } diff --git a/src/server.ts b/src/server.ts index f8acfcd..f05065f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,11 +4,11 @@ import { loadEnvFile } from './runtime.js' import { ensureDataDirectory } from './persist.js' import { initState } from './state-file.js' import { runStartupTasks } from './startup.js' -import { initializeTwitchIntegration } from './integrations/twitch/index.js' +import { initializeTwitchIntegration, stop } from './integrations/twitch/index.js' import { getTwitchClientId } from './env.js' import { clearTwitchAuthorization } from './secrets.js' import { createLogger, describeError, enableFileLogging } from './logger.js' -import { installShutdownHandlers, registerServerStop, shutdown } from './shutdown.js' +import { installShutdownHandlers, registerBackgroundStop, registerServerStop, shutdown } from './shutdown.js' const app = createApp() const log = createLogger('SERVER') @@ -33,6 +33,11 @@ async function main() { // saved before the exit, a request accepted during the save would be lost after it registerServerStop(() => server.close()) + // the Twitch sockets stop before the save too: a reconnect racing it could change what is being saved + registerBackgroundStop(() => { + void stop() + }) + if (port !== preferredPort) { // remembered, so the link does not change again on the next launch log.warn(`Port ${preferredPort} is busy, using ${port} instead. The overlay URL in OBS is now http://localhost:${port}/overlay`) diff --git a/src/shutdown.ts b/src/shutdown.ts index 1787bb6..9efd022 100644 --- a/src/shutdown.ts +++ b/src/shutdown.ts @@ -9,11 +9,20 @@ let shuttingDown = false // The HTTP server registers itself at startup: shutdown stops accepting new requests before saving let stopAcceptingRequests: (() => void) | null = null +// The long-lived background connections (the Twitch sockets) register the same way at startup: +// they are closed before the save, so nothing changes state while it is being written +let stopBackgroundConnections: (() => void) | null = null + /** The one hook the HTTP server needs at shutdown: close the listener so no request lands after the save starts. */ export function registerServerStop(stop: () => void): void { stopAcceptingRequests = stop } +/** The one hook the background connections need at shutdown: they stop before the save starts. */ +export function registerBackgroundStop(stop: () => void): void { + stopBackgroundConnections = stop +} + // If saving never finishes (a stuck disk, a hung write) the process must still end: a crashed Node stays alive otherwise const FORCE_EXIT_AFTER_MS = 5000 @@ -29,6 +38,9 @@ export async function shutdown(code = 0, exit: (code: number) => void = (c) => p // just for its changes to be lost by the save below stopAcceptingRequests?.() + // the background connections stop for the same reason: their work must not change what is being saved + stopBackgroundConnections?.() + // the safety net owns the exit from the moment it fires: the normal path must not add a second one // when its own save completes right after (a hung save can still finish, just too late) let forcedExitStarted = false diff --git a/tests/backend/chat-replies.test.ts b/tests/backend/chat-replies.test.ts index 8383af0..ba72a06 100644 --- a/tests/backend/chat-replies.test.ts +++ b/tests/backend/chat-replies.test.ts @@ -30,6 +30,11 @@ test('buildNowPlayingMessage', async (t) => { assert.equal(buildNowPlayingMessage(playing(playlistTrack), 'en'), 'Now playing: T (requested by Playlist)') assert.equal(buildNowPlayingMessage(playing(playlistTrack), 'ru'), 'Сейчас играет: T (заказал Плейлист)') }) + + await t.test('a track that came without a title shows the localized placeholder, not a blank', () => { + assert.equal(buildNowPlayingMessage(playing(track({ title: '' })), 'en'), 'Now playing: Unknown title (requested by bob)') + assert.equal(buildNowPlayingMessage(playing(track({ title: '' })), 'ru'), 'Сейчас играет: Неизвестное название (заказал bob)') + }) }) test('buildQueueMessage', async (t) => { diff --git a/tests/backend/integrations/twitch/chat-commands.test.ts b/tests/backend/integrations/twitch/chat-commands.test.ts index a86aa5d..0808543 100644 --- a/tests/backend/integrations/twitch/chat-commands.test.ts +++ b/tests/backend/integrations/twitch/chat-commands.test.ts @@ -10,8 +10,8 @@ const { getConfig, updateConfig } = await import('../../../../src/config.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const { hydrateQueue } = await import('../../../../src/queue.js') const { getState, setPaused, getIsPaused } = await import('../../../../src/player.js') -const { _resetIntegration } = await import('../../../../src/integrations/twitch/index.js') -const { hasPermission, matchesCommand, handleChatMessage, resetCommandCooldowns } = await import('../../../../src/integrations/twitch/chat-commands.js') +const { stop: stopIntegration, initializeTwitchIntegration } = await import('../../../../src/integrations/twitch/index.js') +const { hasPermission, matchesCommand, handleChatMessage } = await import('../../../../src/integrations/twitch/chat-commands.js') const { chatConnection } = await import('../../../../src/integrations/twitch/state.js') const { clearTwitchOAuthState } = await import('../../../../src/secrets.js') @@ -33,9 +33,8 @@ function message(overrides: Partial<TwitchChatMessage> = {}): TwitchChatMessage const DEFAULTS = getConfig() beforeEach(async () => { - await _resetIntegration() + await stopIntegration() clearTwitchOAuthState() - resetCommandCooldowns() chatConnection.instance = null hydrateQueue({ current: null, queue: [] }) setPaused(false) @@ -43,9 +42,8 @@ beforeEach(async () => { ...DEFAULTS, fallbackPlaylist: { ...DEFAULTS.fallbackPlaylist } }) - // Reset twitch config to defaults using cloneTwitchConfig and restore - const { restoreTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') - restoreTwitchConfig({ + // Reset twitch config to its defaults: a full snapshot is a valid update and replaces every field + updateTwitchConfig({ channelPointsRewardId: null, autoFulfillRedemptions: false, chatCommands: { @@ -220,9 +218,10 @@ test('handleChatMessage() - control commands (skip/pause/resume)', async (t) => test('a chat reply that waits for a connection', async (t) => { await t.test('is sent as soon as the chat reconnects, not a fixed minute later', async () => { - // short limits, so a lost reply does not take a minute to give up; the real ones are 5s/60s - chatConnection.startWaitMs = 100 - chatConnection.reconnectWaitMs = 300 + // short wait windows, so a lost reply does not take a minute to give up; the real ones are 5s/60s. + // They are options of the initialization, not knobs of the state a test pokes at + await stopIntegration() + initializeTwitchIntegration({ clientId: 'test' }, { chatStartWaitMs: 100, chatReconnectWaitMs: 300 }) const sent: string[] = [] let connected = false @@ -255,9 +254,9 @@ test('a chat reply that waits for a connection', async (t) => { }) await t.test('is dropped, without hanging, when the chat does not come back within its window', async () => { - // short limits, so the "gave up" path does not take a minute; the real ones are 5s/60s - chatConnection.startWaitMs = 100 - chatConnection.reconnectWaitMs = 300 + // short wait windows, so the "gave up" path does not take a minute; the real ones are 5s/60s + await stopIntegration() + initializeTwitchIntegration({ clientId: 'test' }, { chatStartWaitMs: 100, chatReconnectWaitMs: 300 }) const sent: string[] = [] chatConnection.instance = { diff --git a/tests/backend/integrations/twitch/config.test.ts b/tests/backend/integrations/twitch/config.test.ts index 5dec6b3..45acd42 100644 --- a/tests/backend/integrations/twitch/config.test.ts +++ b/tests/backend/integrations/twitch/config.test.ts @@ -7,7 +7,9 @@ import { join } from 'node:path' // config.json is read from process.cwd()/data at import time: keep the test away from the real one process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { getTwitchConfig, updateTwitchConfig, validateTwitchConfigUpdates } = await import('../../../../src/integrations/twitch/config.js') +// the module exports only what production uses (the routes read and update); the tests drive the same +// update path and assert on the config that comes out of it +const { getTwitchConfig, updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const DEFAULTS = getTwitchConfig() @@ -25,101 +27,105 @@ beforeEach(() => { }) }) -test('validateTwitchConfigUpdates()', async (t) => { +test('updateTwitchConfig(): validation', async (t) => { await t.test('accepts every field the config has (a new field without a rule would be refused)', () => { - assert.deepEqual(validateTwitchConfigUpdates(getTwitchConfig()).rejected, []) + assert.deepEqual(updateTwitchConfig(getTwitchConfig()).rejected, []) }) - await t.test('reports invalid top-level values by name and keeps the valid ones', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ channelPointsRewardId: 42 }) + await t.test('reports invalid top-level values by name and keeps the stored value', () => { + const { rejected } = updateTwitchConfig({ channelPointsRewardId: 42 as never }) assert.deepEqual(rejected, ['channelPointsRewardId']) - assert.equal('channelPointsRewardId' in clean, false) + assert.equal(getTwitchConfig().channelPointsRewardId, DEFAULTS.channelPointsRewardId) }) await t.test('treats an empty channelPointsRewardId the same as null - "no reward selected" is valid, not invalid', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ channelPointsRewardId: '' }) + const { rejected } = updateTwitchConfig({ channelPointsRewardId: '' }) assert.deepEqual(rejected, []) - assert.deepEqual(clean, { channelPointsRewardId: null }) + assert.equal(getTwitchConfig().channelPointsRewardId, null) }) await t.test('trims whitespace-only channelPointsRewardId down to null too', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ channelPointsRewardId: ' ' }) + const { rejected } = updateTwitchConfig({ channelPointsRewardId: ' ' }) assert.deepEqual(rejected, []) - assert.deepEqual(clean, { channelPointsRewardId: null }) + assert.equal(getTwitchConfig().channelPointsRewardId, null) }) await t.test('accepts a real reward id and reports it with a dotted name when invalid', () => { - const accepted = validateTwitchConfigUpdates({ channelPointsRewardId: 'reward-123' }) - assert.deepEqual(accepted.rejected, []) - assert.deepEqual(accepted.clean, { channelPointsRewardId: 'reward-123' }) + assert.deepEqual(updateTwitchConfig({ channelPointsRewardId: 'reward-123' }).rejected, []) + assert.equal(getTwitchConfig().channelPointsRewardId, 'reward-123') - const rejected = validateTwitchConfigUpdates({ channelPointsRewardId: 42 }) - assert.deepEqual(rejected.rejected, ['channelPointsRewardId']) + assert.deepEqual(updateTwitchConfig({ channelPointsRewardId: 42 as never }).rejected, ['channelPointsRewardId']) + assert.equal(getTwitchConfig().channelPointsRewardId, 'reward-123', 'a refused update leaves the stored value') }) await t.test('accepts a valid chat command update and normalizes it (trim, lowercase, collapse whitespace)', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ chatCommands: { skip: { command: ' !SG Skip ' } } }) + const { rejected } = updateTwitchConfig({ chatCommands: { skip: { command: ' !SG Skip ' } } }) assert.deepEqual(rejected, []) - assert.deepEqual(clean.chatCommands?.skip, { command: '!sg skip' }) + assert.equal(getTwitchConfig().chatCommands.skip.command, '!sg skip') }) await t.test('rejects a chat command missing the leading "!"', () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { now: { command: 'sg now' } } }) + const { rejected } = updateTwitchConfig({ chatCommands: { now: { command: 'sg now' } } }) assert.deepEqual(rejected, ['chatCommands.now.command']) }) await t.test('rejects a chat command permission outside the known set', () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { skip: { permission: 'vip' } } }) + const { rejected } = updateTwitchConfig({ chatCommands: { skip: { permission: 'vip' as never } } }) assert.deepEqual(rejected, ['chatCommands.skip.permission']) }) await t.test('rejects an unknown command key under chatCommands', () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { teleport: { command: '!sg tp' } } }) + const { rejected } = updateTwitchConfig({ chatCommands: { teleport: { command: '!sg tp' } } } as never) assert.deepEqual(rejected, ['chatCommands.teleport']) }) for (const value of ['x', 5, null, [], true]) { await t.test(`refuses chatCommands = ${JSON.stringify(value)} instead of throwing`, () => { - const { clean, rejected } = validateTwitchConfigUpdates({ chatCommands: value }) + const { rejected } = updateTwitchConfig({ chatCommands: value as never }) assert.deepEqual(rejected, ['chatCommands']) - assert.equal(clean.chatCommands === undefined, true) + assert.deepEqual(getTwitchConfig().chatCommands, DEFAULTS.chatCommands) }) } for (const value of ['x', null, [], true]) { await t.test(`refuses a single chat command = ${JSON.stringify(value)} instead of throwing`, () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { skip: value } }) + const { rejected } = updateTwitchConfig({ chatCommands: { skip: value as never } }) assert.deepEqual(rejected, ['chatCommands.skip']) + assert.equal(getTwitchConfig().chatCommands.skip.command, DEFAULTS.chatCommands.skip.command) }) } await t.test('accepts a valid controlCooldownSeconds', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ chatCommands: { controlCooldownSeconds: 10 } }) + const { rejected } = updateTwitchConfig({ chatCommands: { controlCooldownSeconds: 10 } }) + assert.deepEqual(rejected, []) - assert.equal(clean.chatCommands?.controlCooldownSeconds, 10) + assert.equal(getTwitchConfig().chatCommands.controlCooldownSeconds, 10) }) for (const value of [-1, 301, 'x', null]) { await t.test(`rejects an out-of-range or non-numeric controlCooldownSeconds = ${JSON.stringify(value)}`, () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { controlCooldownSeconds: value } }) + const { rejected } = updateTwitchConfig({ chatCommands: { controlCooldownSeconds: value as never } }) assert.deepEqual(rejected, ['chatCommands.controlCooldownSeconds']) + assert.equal(getTwitchConfig().chatCommands.controlCooldownSeconds, DEFAULTS.chatCommands.controlCooldownSeconds) }) } await t.test('accepts a valid plainCooldownSeconds', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ chatCommands: { plainCooldownSeconds: 10 } }) + const { rejected } = updateTwitchConfig({ chatCommands: { plainCooldownSeconds: 10 } }) + assert.deepEqual(rejected, []) - assert.equal(clean.chatCommands?.plainCooldownSeconds, 10) + assert.equal(getTwitchConfig().chatCommands.plainCooldownSeconds, 10) }) for (const value of [-1, 301, 'x', null]) { await t.test(`rejects an out-of-range or non-numeric plainCooldownSeconds = ${JSON.stringify(value)}`, () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { plainCooldownSeconds: value } }) + const { rejected } = updateTwitchConfig({ chatCommands: { plainCooldownSeconds: value as never } }) assert.deepEqual(rejected, ['chatCommands.plainCooldownSeconds']) + assert.equal(getTwitchConfig().chatCommands.plainCooldownSeconds, DEFAULTS.chatCommands.plainCooldownSeconds) }) } @@ -131,36 +137,41 @@ test('validateTwitchConfigUpdates()', async (t) => { }) await t.test('a mutual exchange of two commands lands in one update', () => { - const { clean, rejected } = validateTwitchConfigUpdates({ + const { rejected } = updateTwitchConfig({ chatCommands: { now: { command: DEFAULTS.chatCommands.skip.command }, skip: { command: DEFAULTS.chatCommands.now.command } } }) assert.deepEqual(rejected, []) - assert.deepEqual([clean.chatCommands?.now?.command, clean.chatCommands?.skip?.command], [DEFAULTS.chatCommands.skip.command, DEFAULTS.chatCommands.now.command]) + assert.deepEqual( + [getTwitchConfig().chatCommands.now.command, getTwitchConfig().chatCommands.skip.command], + [DEFAULTS.chatCommands.skip.command, DEFAULTS.chatCommands.now.command] + ) }) await t.test('two commands moving to the same word are both refused', () => { - const { rejected } = validateTwitchConfigUpdates({ chatCommands: { now: { command: '!sg same' }, skip: { command: '!sg same' } } }) + const { rejected } = updateTwitchConfig({ chatCommands: { now: { command: '!sg same' }, skip: { command: '!sg same' } } }) assert.deepEqual([...rejected].sort(), ['chatCommands.now.command', 'chatCommands.skip.command']) + assert.equal(getTwitchConfig().chatCommands.now.command, DEFAULTS.chatCommands.now.command) + assert.equal(getTwitchConfig().chatCommands.skip.command, DEFAULTS.chatCommands.skip.command) }) - await t.test('refuses unknown fields, including prototype keys', () => { - const { clean, rejected } = validateTwitchConfigUpdates( - JSON.parse('{"foo":1,"__proto__":{"polluted":true},"constructor":1,"channelPointsRewardId":"reward-123"}') + await t.test('refuses unknown fields, including prototype keys, and applies the valid ones', () => { + const { rejected } = updateTwitchConfig( + JSON.parse('{"foo":1,"__proto__":{"polluted":true},"constructor":1,"channelPointsRewardId":"reward-123"}') as never ) assert.deepEqual(rejected.sort(), ['__proto__', 'constructor', 'foo']) - assert.deepEqual(clean, { channelPointsRewardId: 'reward-123' }) + assert.equal(getTwitchConfig().channelPointsRewardId, 'reward-123') assert.equal(({} as Record<string, unknown>).polluted, undefined) }) await t.test('refuses a body that is not an object', () => { - for (const body of [null, 'x', 5, [1, 2]]) assert.deepEqual(validateTwitchConfigUpdates(body).rejected, ['body']) + for (const body of [null, 'x', 5, [1, 2]]) assert.deepEqual(updateTwitchConfig(body as never).rejected, ['body']) }) }) -test('updateTwitchConfig()', async (t) => { +test('updateTwitchConfig(): merging', async (t) => { await t.test('applies valid fields and merges a partial chat command update', () => { const before = getTwitchConfig().chatCommands diff --git a/tests/backend/integrations/twitch/device-auth.test.ts b/tests/backend/integrations/twitch/device-auth.test.ts index 46c50d7..44bde14 100644 --- a/tests/backend/integrations/twitch/device-auth.test.ts +++ b/tests/backend/integrations/twitch/device-auth.test.ts @@ -6,10 +6,9 @@ import { join } from 'node:path' process.chdir(mkdtempSync(join(tmpdir(), 'streamqueue-test-'))) -const { initializeTwitchIntegration, startDeviceAuthorization, disconnect, _resetIntegration } = +const { initializeTwitchIntegration, startDeviceAuthorization, disconnect, stop } = await import('../../../../src/integrations/twitch/index.js') const { handleChannelPointsRedemption } = await import('../../../../src/integrations/twitch/redemptions.js') -const { twitchBackoff } = await import('../../../../src/integrations/twitch/state.js') const { updateTwitchConfig } = await import('../../../../src/integrations/twitch/config.js') const { clearTwitchOAuthState, getSecrets, getPublicSecretsView } = await import('../../../../src/secrets.js') const { onNotice } = await import('../../../../src/state-events.js') @@ -76,14 +75,16 @@ beforeEach(async () => { throw new Error(`unexpected request: ${url}`) }) as typeof fetch - await _resetIntegration() + await stop() clearTwitchOAuthState() - initializeTwitchIntegration({ clientId: 'client-1' }) + // the small retry delay keeps the getUserInfo retries of some subtests fast - an option of the + // initialization, not a knob of the state a test pokes at + initializeTwitchIntegration({ clientId: 'client-1' }, { retryDelayMs: 1 }) }) afterEach(async () => { globalThis.fetch = originalFetch - await _resetIntegration() + await stop() clearTwitchOAuthState() }) @@ -202,7 +203,6 @@ test('device authorization', async (t) => { test('a login whose account details cannot be fetched is rolled back and reported to the panel', async (t) => { await t.test('Twitch is told to forget the tokens, nothing stays on disk, and the panel learns why', async () => { - twitchBackoff.delayMs = 1 // a 403 is a refusal that a retry does not change: it must not be repeated userInfoRefusal = json({ message: 'not yours' }, 403) @@ -226,7 +226,6 @@ test('a login whose account details cannot be fetched is rolled back and reporte }) await t.test('a passing failure is retried while attempts are left, then rolled back the same way', async () => { - twitchBackoff.delayMs = 1 userInfoRefusal = json({ message: 'having a moment' }, 503) await startDeviceAuthorization() @@ -243,7 +242,6 @@ const event = (id: string, userInput = '') => ({ id, user_name: 'viewer', user_i test('the same redemption delivered twice is taken once', async (t) => { updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) - twitchBackoff.delayMs = 1 await t.test('the second delivery of one id is ignored, another id is not', async () => { const calls: string[] = [] diff --git a/tests/backend/integrations/twitch/eventsub.test.ts b/tests/backend/integrations/twitch/eventsub.test.ts index e85c8f9..bc73e51 100644 --- a/tests/backend/integrations/twitch/eventsub.test.ts +++ b/tests/backend/integrations/twitch/eventsub.test.ts @@ -212,7 +212,8 @@ test('TwitchEventSub: a connection lost to the outside', async (t) => { test('TwitchEventSub: the outage line is written when the threshold is crossed, not when the outage ends', async (t) => { // The real threshold is a minute; these subtests keep it real and drive the clock by hand instead. The - // socket talks to a local server over real I/O, so waiting is done with setImmediate - it survives the mocked timers + // socket talks to a local server over real I/O, so waiting is done with setImmediate - it survives the mocked + // timers - and the clock itself is mocked too: the minutes of the journal line are counted with Date.now(). const drainUntil = async (condition: () => boolean, what: string) => { for (let i = 0; i < 1000 && !condition(); i++) await new Promise((resolve) => setImmediate(resolve)) if (!condition()) assert.fail(`timed out waiting for: ${what}`) @@ -220,14 +221,14 @@ test('TwitchEventSub: the outage line is written when the threshold is crossed, const lostLines = () => getActivity().filter((entry) => entry.reasonCode === 'TWITCH_CONNECTION_LOST') - await t.test('an outage that never ends leaves exactly one line, written by the timer', async () => { + await t.test('an outage that never ends leaves exactly one line, written by the timer with the minutes known then', async () => { const { sub, server, login } = await setup() ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 60_000 await sub.connect() login.refused = true // the saved login is gone: no reconnection will end the outage instead of the timer clearActivity() - mock.timers.enable({ apis: ['setTimeout'] }) + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) try { server.dropClients() await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') @@ -235,7 +236,8 @@ test('TwitchEventSub: the outage line is written when the threshold is crossed, mock.timers.tick(60_001) await drainUntil(() => lostLines().length === 1, 'the journal line') - // still no second connection: the line is there anyway, and only one + // still no second connection: the line is there anyway, and only one, saying what was known at the time + assert.deepEqual(lostLines()[0].reasonParams, { minutes: 1 }) assert.equal(server.connections(), 1) } finally { mock.timers.reset() @@ -248,7 +250,7 @@ test('TwitchEventSub: the outage line is written when the threshold is crossed, clearActivity() await sub.connect() - mock.timers.enable({ apis: ['setTimeout'] }) + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) try { server.dropClients() await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') @@ -264,23 +266,31 @@ test('TwitchEventSub: the outage line is written when the threshold is crossed, } }) - await t.test('a recovery over the threshold keeps the timer line, without a second one from the recovery', async () => { + await t.test('a recovery over the threshold keeps the one line and corrects it to the real length', async () => { const { sub, server } = await setup() ;(sub as unknown as { journalAfterMs: number }).journalAfterMs = 60_000 - clearActivity() + // the first reconnect of the test timing is pushed past the threshold: the outage outlives it, + // so the journal line is written before the connection is back + ;(sub as unknown as { timing: { initialDelayMs: number } }).timing.initialDelayMs = 180_000 await sub.connect() + clearActivity() - mock.timers.enable({ apis: ['setTimeout'] }) + mock.timers.enable({ apis: ['setTimeout', 'Date'] }) try { server.dropClients() await drainUntil(() => (sub as unknown as { journalTimer: unknown }).journalTimer !== null, 'the outage alarm to be armed') - // one tick passes both the reconnect delay (5 ms) and the journal threshold (60 s) + // the outage crosses the threshold while the reconnection is still a minute away: the line says the one minute known then mock.timers.tick(60_001) await drainUntil(() => lostLines().length === 1, 'the journal line') + assert.deepEqual(lostLines()[0].reasonParams, { minutes: 1 }, 'written with the minutes known at the threshold') + + // the connection only comes back at the third minute: no second line, the first one learns the real length + mock.timers.tick(120_000) await drainUntil(() => sub.isConnected(), 'the session to be ready again') - assert.equal(lostLines().length, 1, 'exactly one line: the timer wrote it, the recovery added nothing') + assert.equal(lostLines().length, 1, 'exactly one line: the recovery corrected it, it did not add one') + assert.deepEqual(lostLines()[0].reasonParams, { minutes: 3 }, 'the line states the real length of the outage') } finally { mock.timers.reset() } diff --git a/tests/backend/integrations/twitch/redemptions.test.ts b/tests/backend/integrations/twitch/redemptions.test.ts index 7dfea48..117cde3 100644 --- a/tests/backend/integrations/twitch/redemptions.test.ts +++ b/tests/backend/integrations/twitch/redemptions.test.ts @@ -8,10 +8,10 @@ const workDir = mkdtempSync(join(tmpdir(), 'streamqueue-test-')) cpSync(resolve('public/locales'), join(workDir, 'public/locales'), { recursive: true }) process.chdir(workDir) -const { _resetIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') +const { stop: stopIntegration, initializeTwitchIntegration, disconnect } = await import('../../../../src/integrations/twitch/index.js') const { isRetryable } = await import('../../../../src/integrations/twitch/error.js') const { cancelRedemption, fulfillRedemption, handleChannelPointsRedemption } = await import('../../../../src/integrations/twitch/redemptions.js') -const { chatConnection, twitchBackoff } = await import('../../../../src/integrations/twitch/state.js') +const { chatConnection } = await import('../../../../src/integrations/twitch/state.js') const { buildRedemptionRejectionMessage, buildRedemptionRefundFailedMessage } = await import('../../../../src/chat-replies.js') const { AppError } = await import('../../../../src/types.js') const { updateTwitchOAuthState, updateSecrets } = await import('../../../../src/secrets.js') @@ -48,16 +48,19 @@ function fakeClient(...answers: unknown[]): TwitchClient { } as unknown as TwitchClient } -beforeEach(() => { +beforeEach(async () => { sent = [] calls = [] - twitchBackoff.delayMs = 1 + // stop() first, so every test starts from a fresh integration; the small retry delay keeps the + // suites fast - it is an option of the initialization, not a knob of the state a test pokes at + await stopIntegration() + initializeTwitchIntegration({ clientId: 'client-1' }, { retryDelayMs: 1 }) chatConnection.instance = { isConnected: () => true, disconnect: async () => {}, sendMessage: async (message: string) => void sent.push(message) } as unknown as TwitchChat }) after(async () => { chatConnection.instance = null - await _resetIntegration() + await stopIntegration() }) test('isRetryable()', async (t) => { @@ -144,7 +147,7 @@ test('fulfillRedemption()', async (t) => { test('disconnect()', async (t) => { await t.test('without an integration there is nothing left open', async () => { - await _resetIntegration() + await stopIntegration() assert.equal(await disconnect(), 0) }) @@ -175,9 +178,7 @@ test('handleChannelPointsRedemption()', async (t) => { }) t.beforeEach(() => { - initializeTwitchIntegration({ clientId: 'client-1' }) updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) - twitchBackoff.delayMs = 1 calls = [] }) @@ -237,9 +238,9 @@ test('handleChannelPointsRedemption()', async (t) => { await t.test('a failure after the track is queued does not refund the points', async () => { // a token in storage makes the account look authenticated, so the flow reaches the chat reply - await _resetIntegration() + await stopIntegration() updateTwitchOAuthState({ tokenData: { accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() + 3600_000, scope: [] } }) - initializeTwitchIntegration({ clientId: 'client-1' }) + initializeTwitchIntegration({ clientId: 'client-1' }, { retryDelayMs: 1 }) updateTwitchConfig({ channelPointsRewardId: 'reward-1' }) queue.clearQueue() @@ -279,7 +280,7 @@ test('handleChannelPointsRedemption()', async (t) => { } finally { globalThis.fetch = realFetch chatConnection.instance = null - await _resetIntegration() + await stopIntegration() updateTwitchOAuthState({ tokenData: null }) } diff --git a/tests/backend/integrations/twitch/twitch.test.ts b/tests/backend/integrations/twitch/twitch.test.ts index 7f22e0f..7471ece 100644 --- a/tests/backend/integrations/twitch/twitch.test.ts +++ b/tests/backend/integrations/twitch/twitch.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { TwitchOAuth } from '../../../../src/integrations/twitch/oauth.js' -import { initializeTwitchIntegration, disconnect, _resetIntegration } from '../../../../src/integrations/twitch/index.js' +import { initializeTwitchIntegration, disconnect, stop } from '../../../../src/integrations/twitch/index.js' import { clearTwitchOAuthState, getPublicSecretsView } from '../../../../src/secrets.js' import type { TwitchTokenData } from '../../../../src/integrations/twitch/types.js' @@ -100,7 +100,7 @@ test('TwitchOAuth', async (t) => { test('TwitchIntegration', async (t) => { t.beforeEach(async () => { - await _resetIntegration() + await stop() clearTwitchOAuthState() }) diff --git a/tests/frontend/control/activity.test.js b/tests/frontend/control/activity.test.js index ba6fcbb..7e305aa 100644 --- a/tests/frontend/control/activity.test.js +++ b/tests/frontend/control/activity.test.js @@ -35,6 +35,20 @@ test('loadActivity()', async (t) => { assert.equal(panel.toasts().join('|'), before) }) + await t.test('a track that came without a title shows the localized placeholder; an unresolved request keeps its query', async () => { + panel.route('GET /api/activity', { + entries: [ + entry('d', { title: '' }), + entry('e', { title: null, status: 'rejected', reasonCode: 'SONG_NOT_FOUND' }) + ] + }) + + await loadActivity() + + const titles = panel.$$('#activityListWrapper .column-info .text-primary').map((node) => node.textContent) + assert.deepEqual(titles, ['Unknown title', 'q']) + }) + await t.test('a silent read that fails says nothing and leaves the list as it was', async () => { panel.route('GET /api/activity', [500, { error: 'raw', code: 'SERVER_ERROR' }]) const before = panel.toasts().join('|') From 1878852b26e2053af621de1e696ba58a458c70ab Mon Sep 17 00:00:00 2001 From: Ayhal <54825050+choompuy@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:58:21 +0700 Subject: [PATCH 22/22] Merge main: harden frontend requests and playback handling Networking: - centralize HTTP requests and action error reporting - add request timeouts and prevent stale search or form responses from overwriting newer input Playback: - add playback-start timeouts - improve overlay progress and playlist behavior UI: - improve copy feedback --- public/index.html | 2 +- public/js/control/actions.js | 17 ++- public/js/control/activity.js | 21 +-- public/js/control/api.js | 64 ++------ public/js/control/blocklist.js | 16 +- public/js/control/events.js | 13 -- public/js/control/fallback-actions.js | 56 +++---- public/js/control/fallback.js | 10 +- public/js/control/form.js | 11 +- public/js/control/index.js | 4 +- public/js/control/player-actions.js | 5 +- public/js/control/player.js | 8 +- public/js/control/playlists.js | 31 ++-- public/js/control/queue.js | 10 +- public/js/control/run.js | 26 ++-- public/js/control/search.js | 65 ++++---- public/js/control/settings.js | 97 +++++++----- public/js/control/state.js | 1 - public/js/control/tabs.js | 3 +- public/js/control/toast.js | 37 ++--- .../js/control/twitch/twitch-chat-commands.js | 25 ++- public/js/control/twitch/twitch-connection.js | 68 ++++----- public/js/control/twitch/twitch-rewards.js | 53 +++---- public/js/control/ui.js | 21 +-- public/js/control/views/fallback.js | 4 +- public/js/control/views/queue.js | 4 +- public/js/control/views/search.js | 4 +- public/js/{sse.js => events.js} | 142 +++++------------- public/js/http.js | 57 +++++++ public/js/i18n.js | 9 +- public/js/overlay.js | 125 +++++++++------ public/js/playback-lock.js | 12 +- public/js/serialized.js | 38 +++++ public/js/shared.js | 40 ++--- public/js/watch.js | 62 ++++++++ public/js/youtube-api.js | 2 +- public/locales/en.json | 14 +- public/locales/ru.json | 16 +- public/overlay.html | 2 +- tests/frontend/control/actions.test.js | 28 ++++ tests/frontend/control/api.test.js | 24 +++ tests/frontend/control/blocklist.test.js | 4 +- tests/frontend/control/events.test.js | 12 -- .../frontend/control/fallback-actions.test.js | 4 +- tests/frontend/control/fallback.test.js | 73 +++++++++ tests/frontend/control/form.test.js | 14 ++ tests/frontend/control/panel-env.js | 6 + tests/frontend/control/player-actions.test.js | 4 +- tests/frontend/control/queue.test.js | 4 +- tests/frontend/control/run.test.js | 12 -- tests/frontend/control/search.test.js | 73 ++++++++- tests/frontend/control/settings-copy.test.js | 76 ++++++++++ .../frontend/control/settings.twitch.test.js | 11 +- tests/frontend/control/toast.test.js | 22 +++ .../control/twitch-reward-form.test.js | 1 + tests/frontend/http.test.js | 78 ++++++++++ tests/frontend/i18n.test.js | 11 +- tests/frontend/locales.test.js | 16 ++ tests/frontend/overlay.test.js | 67 +++++++++ tests/frontend/shared.test.js | 51 +++---- tests/frontend/sse.test.js | 42 +++++- 61 files changed, 1180 insertions(+), 648 deletions(-) rename public/js/{sse.js => events.js} (50%) create mode 100644 public/js/http.js create mode 100644 public/js/serialized.js create mode 100644 public/js/watch.js create mode 100644 tests/frontend/control/fallback.test.js create mode 100644 tests/frontend/control/settings-copy.test.js create mode 100644 tests/frontend/http.test.js create mode 100644 tests/frontend/locales.test.js diff --git a/public/index.html b/public/index.html index a09a1a2..8adcd1c 100644 --- a/public/index.html +++ b/public/index.html @@ -489,7 +489,7 @@ <h2 data-i18n="settings.bot.title">Bot Settings</h2> id="secYoutubeKey" placeholder="leave empty to keep unchanged" data-i18n-placeholder="settings.bot.apiKeyPlaceholder" - autocomplete="off" + autocomplete="new-password" /> <div id="secretsStatus" class="text-muted text-xs"></div> </label> diff --git a/public/js/control/actions.js b/public/js/control/actions.js index b2f0a07..4aaa8c8 100644 --- a/public/js/control/actions.js +++ b/public/js/control/actions.js @@ -1,5 +1,6 @@ import { t } from '../i18n.js' import { log } from './log.js' +import { run } from './run.js' import { playerActions } from './player-actions.js' import { searchActions } from './search.js' import { queueActions } from './queue.js' @@ -37,8 +38,9 @@ export const ACTIONS = createActionRegistry( menuActions ) -// Every button of the panel goes through here, so what all of them share is done once: -// a button with data-confirm asks first, and a button whose action takes time stays disabled until the action is over +// Every button of the panel goes through here, so what all of them share is done once: a button with data-confirm asks first, +// a failure of the action is logged and shown (so an action does not handle its own failures), and a button whose action +// takes time stays disabled until the action is over export function dispatchAction(element, event) { const name = element.dataset.action const handler = ACTIONS[name] @@ -50,14 +52,15 @@ export function dispatchAction(element, event) { if (element.dataset.confirm && !confirm(t(element.dataset.confirm))) return undefined - const result = handler(element, event) + let returned + const result = run(name, () => (returned = handler(element, event))) - if (typeof result?.then === 'function' && 'disabled' in element) { + // the handler has been called by now. A button that is disabled loses the focus, so only one that waits for something is + if (typeof returned?.then === 'function' && 'disabled' in element) { element.disabled = true - const enable = () => { + result.then(() => { element.disabled = false - } - result.then(enable, enable) + }) } return result diff --git a/public/js/control/activity.js b/public/js/control/activity.js index 20343cc..0a2b34a 100644 --- a/public/js/control/activity.js +++ b/public/js/control/activity.js @@ -8,10 +8,8 @@ import { t } from '../i18n.js' import { setText } from '../shared.js' function renderActivity() { - const filter = state.activityFilter - const entries = filter === 'all' ? state.activity : state.activity.filter((entry) => entry.status === filter) - views.activity.render(entries) - setText(dom.activityCount, t('activity.requests', { count: entries.length })) + views.activity.render(state.activity) + setText(dom.activityCount, t('activity.requests', { count: state.activity.length })) } export function loadActivity(silent = false) { @@ -28,16 +26,11 @@ export function loadActivity(silent = false) { } export async function clearActivity() { - await run( - 'clearing activity', - async () => { - const data = await api.clearActivity() - state.activity = data.entries - views.activity.invalidate() - renderActivity() - renderStats() - } - ) + const data = await api.clearActivity() + state.activity = data.entries + views.activity.invalidate() + renderActivity() + renderStats() } export const activityActions = { diff --git a/public/js/control/api.js b/public/js/control/api.js index fa5c1be..9296e00 100644 --- a/public/js/control/api.js +++ b/public/js/control/api.js @@ -1,12 +1,6 @@ -export class ApiError extends Error { - constructor(message, { status, code, params, cause } = {}) { - super(message, { cause }) - this.name = 'ApiError' - this.status = status - this.code = code - this.params = params - } -} +import { request as http, ApiError } from '../http.js' + +export { ApiError } // What the panel does after it changed something on the server: while the events are not open nothing else would tell it to read again let afterChange = () => {} @@ -14,53 +8,17 @@ export const onServerChange = (callback) => { afterChange = callback } -// `partial`: the endpoint answers a save with some refused fields as INVALID_CONFIG plus the stored config and the list of refused fields, -// which is a result to show and not a failure -async function request(url, options = {}, { partial = false } = {}) { - let response - - try { - response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }) - } catch (cause) { - // fetch rejects only when the server cannot be reached at all - throw new ApiError('Network error', { code: 'NETWORK_ERROR', cause }) - } - - let body = null - - try { - body = await response.json() - } catch { - // Empty/non-JSON response - } - - if (!response.ok) { - if (partial && body?.code === 'INVALID_CONFIG' && body?.data) { - if (options.method) afterChange() - return body.data - } - - throw new ApiError(body?.error || `Request failed: ${response.status}`, { - status: response.status, - code: body?.code, - params: body?.params - }) - } - +async function request(url, options = {}, flags) { + const data = await http(url, options, flags) if (options.method) afterChange() - return body?.data ?? null + return data } -const post = (url, body, flags) => request(url, { method: 'POST', ...(body !== undefined && { body: JSON.stringify(body) }) }, flags) -const put = (url, body, flags) => request(url, { method: 'PUT', body: JSON.stringify(body) }, flags) -const patch = (url, body, flags) => request(url, { method: 'PATCH', body: JSON.stringify(body) }, flags) -const del = (url) => request(url, { method: 'DELETE' }) +const send = (method) => (url, body, flags) => request(url, { method, ...(body !== undefined && { body: JSON.stringify(body) }) }, flags) +const post = send('POST') +const put = send('PUT') +const patch = send('PATCH') +const del = send('DELETE') const id = encodeURIComponent export const api = { diff --git a/public/js/control/blocklist.js b/public/js/control/blocklist.js index 1546f70..74cf921 100644 --- a/public/js/control/blocklist.js +++ b/public/js/control/blocklist.js @@ -19,18 +19,14 @@ export function loadBlocklist(silent = false) { ) } -export function blockTrack(videoId, title) { - return run('blocking track', async () => { - await api.blockTrack(videoId, title) - toastSuccess(t('toast.trackBlocked')) - }) +export async function blockTrack(videoId, title) { + await api.blockTrack(videoId, title) + toastSuccess(t('toast.trackBlocked')) } -export function unblockTrack(videoId) { - return run('unblocking track', async () => { - await api.unblockTrack(videoId) - toastSuccess(t('toast.trackUnblocked')) - }) +export async function unblockTrack(videoId) { + await api.unblockTrack(videoId) + toastSuccess(t('toast.trackUnblocked')) } export const blocklistActions = { diff --git a/public/js/control/events.js b/public/js/control/events.js index 863a92b..0f6908f 100644 --- a/public/js/control/events.js +++ b/public/js/control/events.js @@ -5,7 +5,6 @@ import { bindMenus } from './menu.js' import { saveOverlaySettings, onIpChange, changeLocale } from './settings.js' import { onTwitchRewardChange, bindTwitchFieldTracking, bindTwitchRewardFormEvents } from './twitch/index.js' import { trackForm } from './form.js' -import { setError } from '../shared.js' export function bindEvents() { bindMenus() @@ -41,18 +40,6 @@ export function bindEvents() { dom.twitchRewardSelect.addEventListener('change', onTwitchRewardChange) dom.localeSelect.addEventListener('change', (event) => changeLocale(event.target.value)) - const settingsInputs = [ - dom.secYoutubeKey, - dom.localeSelect - ] - - settingsInputs.forEach((input) => { - if (input) { - input.addEventListener('input', () => setError(input, false)) - input.addEventListener('change', () => setError(input, false)) - } - }) - trackForm(dom.botSettingsPanel) bindTwitchFieldTracking() bindTwitchRewardFormEvents() diff --git a/public/js/control/fallback-actions.js b/public/js/control/fallback-actions.js index 2037bee..e10de0e 100644 --- a/public/js/control/fallback-actions.js +++ b/public/js/control/fallback-actions.js @@ -1,62 +1,46 @@ import { api } from './api.js' import { state } from './state.js' import { views } from './views/index.js' -import { run } from './run.js' import { renderFallback } from './fallback.js' import { t } from '../i18n.js' import { toastSuccess } from './toast.js' -export function refreshFallback() { - return run( - 'refreshing fallback', - async () => { - state.fallback = await api.refreshFallback() - renderFallback() - toastSuccess(t('toast.fallbackRefreshed')) - } - ) +export async function refreshFallback() { + state.fallback = await api.refreshFallback() + renderFallback() + toastSuccess(t('toast.fallbackRefreshed')) } -export function toggleFallbackShuffle() { - return run('toggling shuffle', async () => { - state.fallback = await api.shuffleFallback() - views.fallback.invalidate() - renderFallback() - }) +export async function toggleFallbackShuffle() { + state.fallback = await api.shuffleFallback() + views.fallback.invalidate() + renderFallback() } -export function toggleFallbackRepeat() { - return run('toggling repeat', async () => { - state.fallback = await api.repeatFallback() - renderFallback() - }) +export async function toggleFallbackRepeat() { + state.fallback = await api.repeatFallback() + renderFallback() } -export function toggleFallbackEnabled() { - return run('toggling enabled', async () => { - state.fallback = await api.enabledFallback() - renderFallback() - }) +export async function toggleFallbackEnabled() { + state.fallback = await api.enabledFallback() + renderFallback() } const fallbackTitle = (videoId) => state.fallback?.upNext?.find((track) => track.videoId === videoId)?.title -export function playFallbackNow(videoId) { +export async function playFallbackNow(videoId) { const title = fallbackTitle(videoId) - return run('playing fallback track', async () => { - await api.playFallback(videoId) - if (title) toastSuccess(t('toast.nowPlaying', { title })) - }) + await api.playFallback(videoId) + if (title) toastSuccess(t('toast.nowPlaying', { title })) } -export function enqueueFallbackTrack(videoId) { +export async function enqueueFallbackTrack(videoId) { const title = fallbackTitle(videoId) - return run('queueing fallback track', async () => { - const answer = await api.enqueueFallback(videoId) - if (title) toastSuccess(t('toast.addedToQueue', { title, position: answer.state.queue.length })) - }) + const answer = await api.enqueueFallback(videoId) + if (title) toastSuccess(t('toast.addedToQueue', { title, position: answer.state.queue.length })) } export const fallbackActions = { diff --git a/public/js/control/fallback.js b/public/js/control/fallback.js index 6744667..9fb8a5e 100644 --- a/public/js/control/fallback.js +++ b/public/js/control/fallback.js @@ -4,7 +4,7 @@ import { dom } from './dom.js' import { renderStats } from './stats.js' import { views } from './views/index.js' import { run } from './run.js' -import { toggleActive, formatDateTime } from './ui.js' +import { formatDateTime } from './ui.js' import { t } from '../i18n.js' import { setText } from '../shared.js' @@ -23,12 +23,14 @@ export function renderFallback() { const data = state.fallback const tracks = data?.upNext ?? [] - toggleActive(dom.fallbackShuffleBtn, data?.shuffle) - toggleActive(dom.fallbackRepeatBtn, data?.repeat) - toggleActive(dom.fallbackEnabledBtn, data?.enabled) + dom.fallbackShuffleBtn.classList.toggle('active', Boolean(data?.shuffle)) + dom.fallbackRepeatBtn.classList.toggle('active', Boolean(data?.repeat)) + dom.fallbackEnabledBtn.classList.toggle('active', Boolean(data?.enabled)) setText(dom.fallbackEnabledText, data?.enabled ? t('common.on') : t('common.off')) if (!tracks.length) { + // the list is built again when tracks come back: it follows the playing track again then + renderedActiveVideoId = undefined // an active playlist that has simply run out still counts as set; only a section with no playlist at all gets the hint setText(dom.fallbackInfo, data?.playlistId ? '' : t('fallback.notSet')) views.fallback.render([]) diff --git a/public/js/control/form.js b/public/js/control/form.js index 390ae26..b1da234 100644 --- a/public/js/control/form.js +++ b/public/js/control/form.js @@ -108,12 +108,17 @@ export function trackForm(form) { * Returns { config, rejected, saved, refused } for toastSaveResult */ export async function saveForm(form, save, { changedOnly = false } = {}) { - const changed = new Map(formInputs(form).map((input) => [input, isChanged(input)])) + const inputs = formInputs(form) + const changed = new Map(inputs.map((input) => [input, isChanged(input)])) + const sent = new Map(inputs.map((input) => [input, valueOf(input)])) const { config, rejected } = await save(readForm(form, { changedOnly })) let saved = 0 let refused = 0 - fillForm(form, config, { skip: rejected }) + // what was typed while the request was on its way stays as typed: the answer is older than that + const edited = inputs.filter((input) => valueOf(input) !== sent.get(input)) + + fillForm(form, config, { skip: [...rejected, ...edited.map((input) => input.name)] }) for (const [input, wasChanged] of changed) { const isRefused = rejected.includes(input.name) @@ -124,6 +129,8 @@ export async function saveForm(form, save, { changedOnly = false } = {}) { if (isRefused || wasChanged) setFieldState(input, isRefused ? 'error' : 'saved') } + edited.forEach(markChanged) + return { config, rejected, saved, refused } } diff --git a/public/js/control/index.js b/public/js/control/index.js index 0efe311..a73eee6 100644 --- a/public/js/control/index.js +++ b/public/js/control/index.js @@ -5,7 +5,7 @@ import { initI18n } from '../i18n.js' import './player.js' import { run } from './run.js' import { bindEvents } from './events.js' -import { watch } from '../sse.js' +import { watch } from '../watch.js' import { refreshState } from './queue.js' import { refreshFallbackState } from './fallback.js' import { loadActivity } from './activity.js' @@ -15,7 +15,6 @@ import { loadSecrets, loadConfig, loadOverlaySettings, loadNetworkInfo } from '. import { loadTwitchSettings, loadTwitchConfig, loadTwitchSecrets, refreshTwitchStatus } from './twitch/index.js' import { isDashboardActive, isPlaylistsActive, isSettingsActive } from './tabs.js' import { showNotice } from './notices.js' -import { setError } from '../shared.js' // What each topic reads again. Only what the person looks at is read: the rest is read when it is shown again const TOPICS = { @@ -40,7 +39,6 @@ export async function startControlPanel() { const locale = settings?.locale await initI18n(locale) - setError(dom.localeSelect, false) dom.localeSelect.value = locale ?? dom.localeSelect.value bindEvents() diff --git a/public/js/control/player-actions.js b/public/js/control/player-actions.js index 5408b7e..6021707 100644 --- a/public/js/control/player-actions.js +++ b/public/js/control/player-actions.js @@ -1,13 +1,12 @@ import { api } from './api.js' import { state } from './state.js' -import { run } from './run.js' export function playPauseCurrent() { - return run('toggling resume/pause', () => (state.isPaused ? api.resume() : api.pause())) + return state.isPaused ? api.resume() : api.pause() } export function skipCurrent() { - return run('skipping', () => api.skip()) + return api.skip() } export const playerActions = { diff --git a/public/js/control/player.js b/public/js/control/player.js index 56a8fe9..0ad8fbc 100644 --- a/public/js/control/player.js +++ b/public/js/control/player.js @@ -1,4 +1,4 @@ -import { displayChannel, displayTitle, formatDuration, formatViews, getErrorMessage, show, setText } from '../shared.js' +import { displayChannel, displayTitle, formatTrackDuration, formatViews, show, setText } from '../shared.js' import { PLAY_ICON, PAUSE_ICON } from '../icons.js' import { state } from './state.js' import { dom } from './dom.js' @@ -23,7 +23,7 @@ export function renderCurrent() { setText(dom.currentTitle, displayTitle(current.title)) setText(dom.currentChannel, displayChannel(current.channelTitle)) - setText(dom.currentDuration, formatDuration(current.duration)) + setText(dom.currentDuration, formatTrackDuration(current.duration)) setText(dom.currentViews, t('player.views', { count: formatViews(current.views, getCurrentLocale()) })) setText(dom.currentRequester, requesterName(current)) } @@ -34,7 +34,7 @@ export function renderNext() { if (!next) return setText(dom.nextTitle, displayTitle(next.title)) - setText(dom.nextDuration, formatDuration(next.duration)) + setText(dom.nextDuration, formatTrackDuration(next.duration)) } export function renderPlayPause() { @@ -64,7 +64,7 @@ function onPlayerReady() { } function onPlayerError(event) { - log(`Player error: ${getErrorMessage(event.data, t)}`) + log(`Player error: ${event.data}`) } loadYouTubeApi().then(() => { diff --git a/public/js/control/playlists.js b/public/js/control/playlists.js index 00a590c..0825ea9 100644 --- a/public/js/control/playlists.js +++ b/public/js/control/playlists.js @@ -23,31 +23,26 @@ export function renderPlaylists() { views.playlists.render(state.playlists) } -export function addPlaylist() { +export async function addPlaylist() { const value = dom.playlistUrlInput.value.trim() if (!value) return undefined - return run('adding playlist', async () => { - await api.addPlaylist(value) - setValue(dom.playlistUrlInput, '') - toastSuccess(t('toast.playlistAdded')) - }) + await api.addPlaylist(value) + setValue(dom.playlistUrlInput, '') + toastSuccess(t('toast.playlistAdded')) } -export function activatePlaylist(id) { - return run('activating playlist', async () => { - const playlist = state.playlists.find(p => p.id === id) - const wasActive = playlist?.isActive - await api.activatePlaylist(id) - toastSuccess(wasActive ? t('toast.playlistDeactivated') : t('toast.playlistActivated')) - }) +export async function activatePlaylist(id) { + const playlist = state.playlists.find((p) => p.id === id) + const wasActive = playlist?.isActive + + await api.activatePlaylist(id) + toastSuccess(wasActive ? t('toast.playlistDeactivated') : t('toast.playlistActivated')) } -export function deletePlaylist(id) { - return run('deleting playlist', async () => { - await api.removePlaylist(id) - toastSuccess(t('toast.playlistDeleted')) - }) +export async function deletePlaylist(id) { + await api.removePlaylist(id) + toastSuccess(t('toast.playlistDeleted')) } export const playlistActions = { diff --git a/public/js/control/queue.js b/public/js/control/queue.js index 5b77350..6b2e24d 100644 --- a/public/js/control/queue.js +++ b/public/js/control/queue.js @@ -54,15 +54,13 @@ export function renderQueue() { views.queue.render(state.queue) } -export function removeFromQueue(videoId) { - return run('removing from queue', async () => { - await api.removeFromQueue(videoId) - toastSuccess(t('toast.removedFromQueue')) - }) +export async function removeFromQueue(videoId) { + await api.removeFromQueue(videoId) + toastSuccess(t('toast.removedFromQueue')) } export function clearQueue() { - return run('clearing queue', () => api.clearQueue()) + return api.clearQueue() } export const queueActions = { diff --git a/public/js/control/run.js b/public/js/control/run.js index 3fe97e3..2edeb75 100644 --- a/public/js/control/run.js +++ b/public/js/control/run.js @@ -8,17 +8,19 @@ export function errorMessage(error) { return translateErrorCode(t, error?.code, error?.params, error?.message || String(error)) } -export function run(label, action, { silent = false, onError = null } = {}) { - const execute = async () => { - try { - return await action() - } catch (error) { - log(`Error ${label}:`, error) - if (!silent) toastError(error instanceof ApiError ? errorMessage(error) : t('api.errors.unexpectedError')) - await onError?.(error) - return undefined - } - } +// What the person is told about a failure: what the server said about it, or that something went wrong for what is not the server's +export function reportError(error) { + toastError(error instanceof ApiError ? errorMessage(error) : t('api.errors.unexpectedError')) +} - return execute() +// Everything that can fail on the panel goes through here: logged, shown, and never left to end up as an unhandled rejection. +// A failure gives undefined +export async function run(label, action, { silent = false } = {}) { + try { + return await action() + } catch (error) { + log(`Error ${label}:`, error) + if (!silent) reportError(error) + return undefined + } } diff --git a/public/js/control/search.js b/public/js/control/search.js index 140b852..9cc0712 100644 --- a/public/js/control/search.js +++ b/public/js/control/search.js @@ -1,50 +1,63 @@ import { api } from './api.js' import { dom } from './dom.js' import { views } from './views/index.js' -import { run } from './run.js' import { t } from '../i18n.js' import { toastSuccess } from './toast.js' import { show } from '../shared.js' -const YOUTUBE_URL_HINT = /(?:https?:\/\/)?(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com|youtu\.be)\// +const YOUTUBE_HOSTS = new Set(['youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtube-nocookie.com', 'www.youtube-nocookie.com', 'youtu.be']) + +// A link is one word that points at a page of YouTube. Words that only mention one ("lofi youtube.com/ mix") are a search +function isYouTubeLink(query) { + if (/\s/.test(query)) return false + + try { + const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(query) ? query : `https://${query}`) + return YOUTUBE_HOSTS.has(url.hostname) && url.pathname.length > 1 + } catch { + return false + } +} + +// the number of the last search: an answer that comes after a newer search (or after the results were cleared) is dropped +let lastSearch = 0 export function clearSearchResults() { + lastSearch++ views.search.clear() dom.searchInput.value = '' show(dom.searchListWrapper, false) } -export function search() { +export async function search() { const query = dom.searchInput.value.trim() if (!query) return - return run( - 'searching', - async () => { - if (YOUTUBE_URL_HINT.test(query)) { - await addSong(query) - return - } - - const data = await api.search(query) - views.search.render(data.results) - show(dom.searchListWrapper) - } + const thisSearch = ++lastSearch + + if (isYouTubeLink(query)) { + await requestSong(query) + // the link is done with; what was typed meanwhile is not touched + if (dom.searchInput.value.trim() === query) dom.searchInput.value = '' + return + } + + const data = await api.search(query) + if (thisSearch !== lastSearch) return + + views.search.render(data.results) + show(dom.searchListWrapper) +} + +async function requestSong(query) { + const data = await api.requestSong(query) + toastSuccess( + data.started ? t('toast.nowPlaying', { title: data.song.title }) : t('toast.addedToQueue', { title: data.song.title, position: data.position }) ) } export function addSong(query) { - return run( - 'adding song', - async () => { - const data = await api.requestSong(query) - toastSuccess( - data.started - ? t('toast.nowPlaying', { title: data.song.title }) - : t('toast.addedToQueue', { title: data.song.title, position: data.position }) - ) - } - ) + return requestSong(query) } export const searchActions = { diff --git a/public/js/control/settings.js b/public/js/control/settings.js index 56c5791..b9d0864 100644 --- a/public/js/control/settings.js +++ b/public/js/control/settings.js @@ -7,7 +7,7 @@ import { run } from './run.js' import { syncPlayer } from './player.js' import { renderQueue } from './queue.js' import { t } from '../i18n.js' -import { toastError } from './toast.js' +import { toastError, toastSuccess } from './toast.js' import { fillForm, saveForm, toastSaveResult, formInputs, isChanged } from './form.js' let qrCodePromise @@ -19,7 +19,12 @@ function loadQRCode() { const script = document.createElement('script') script.src = 'vendor/qrcode.min.js' script.onload = () => resolve(window.QRCode) - script.onerror = reject + script.onerror = () => { + // a script that failed must not be remembered as the answer for good: the next try loads it again + qrCodePromise = undefined + script.remove() + reject(new Error('The QR code script did not load')) + } document.head.appendChild(script) }) @@ -67,10 +72,28 @@ export function renderOverlayUrl() { dom.overlayUrl.textContent = url } -export function copyOverlayUrl() { - navigator.clipboard?.writeText(dom.overlayUrl.href).catch((error) => { +// The clipboard of the browser exists only on a page that is secure (localhost), not on the one opened by the LAN address: there the text is selected and copied +function copyBySelection(element) { + const range = document.createRange() + range.selectNodeContents(element) + + const selection = window.getSelection() + selection.removeAllRanges() + selection.addRange(range) + + if (!document.execCommand('copy')) throw new Error('The copy command was refused') +} + +export async function copyOverlayUrl() { + try { + if (navigator.clipboard) await navigator.clipboard.writeText(dom.overlayUrl.href) + else copyBySelection(dom.overlayUrl) + + toastSuccess(t('toast.copied')) + } catch (error) { log('Copy failed:', error) - }) + toastError(t('toast.copyFailed')) + } } export function loadNetworkInfo() { @@ -104,7 +127,15 @@ function renderIpSelect() { export async function renderQrUrl() { if (dom.controlPanelQr.classList.contains('hidden') || !state.selectedIp) return - const QRCode = await loadQRCode() + let QRCode + + try { + QRCode = await loadQRCode() + } catch (error) { + log('QR code failed to load:', error) + return + } + if (typeof QRCode === 'undefined') return const { port } = state.network @@ -155,38 +186,36 @@ export function loadSecrets() { }) } -export function saveConfigSetting() { +export async function saveConfigSetting() { const youtubeApiKey = dom.secYoutubeKey.value.trim() - return run('saving config', async () => { - // partial save: the server stores every valid field and lists the refused ones - const result = await saveForm( - dom.botSettingsPanel, - async (config) => { - const answer = await api.updateConfig(config) - state.config = answer.config - return answer - }, - { changedOnly: true } - ) - - let apiKeyFailed = false - - if (youtubeApiKey) { - try { - await api.updateSecrets({ youtubeApiKey }) - dom.secYoutubeKey.value = '' - await loadSecrets() - } catch (error) { - log('Error saving API key:', error) - toastError(t('toast.apiKeyNotSaved')) - apiKeyFailed = true - } + // partial save: the server stores every valid field and lists the refused ones + const result = await saveForm( + dom.botSettingsPanel, + async (config) => { + const answer = await api.updateConfig(config) + state.config = answer.config + return answer + }, + { changedOnly: true } + ) + + let apiKeyFailed = false + + if (youtubeApiKey) { + try { + await api.updateSecrets({ youtubeApiKey }) + dom.secYoutubeKey.value = '' + await loadSecrets() + } catch (error) { + log('Error saving API key:', error) + toastError(t('toast.apiKeyNotSaved')) + apiKeyFailed = true } + } - renderQueue() - toastSaveResult(result, 'toast.settingsSaved', { successToast: !apiKeyFailed }) - }) + renderQueue() + toastSaveResult(result, 'toast.settingsSaved', { successToast: !apiKeyFailed }) } export const settingsActions = { diff --git a/public/js/control/state.js b/public/js/control/state.js index 428e3a4..37ec287 100644 --- a/public/js/control/state.js +++ b/public/js/control/state.js @@ -17,7 +17,6 @@ export const state = { network: null, selectedIp: null, activity: [], - activityFilter: 'all', blocklist: [], twitch: { diff --git a/public/js/control/tabs.js b/public/js/control/tabs.js index 97b562a..301e35d 100644 --- a/public/js/control/tabs.js +++ b/public/js/control/tabs.js @@ -48,8 +48,9 @@ export function switchSection(sectionName) { wrapper.querySelectorAll('.section-tab').forEach((el) => { show(el, el.dataset.section === sectionName) - if (el.dataset.section === 'playlist') scrollToActiveFallback() }) + // the list could not be scrolled while it was hidden + if (sectionName === 'playlist') scrollToActiveFallback() wrapper.querySelectorAll('.section-tab-btn').forEach((el) => { setClass(el, 'active', el.dataset.sectionTarget === sectionName) }) diff --git a/public/js/control/toast.js b/public/js/control/toast.js index 552d0e3..e57f691 100644 --- a/public/js/control/toast.js +++ b/public/js/control/toast.js @@ -1,53 +1,42 @@ import { CLOSE_ICON } from '../icons.js' import { setClass } from '../shared.js' import { t } from '../i18n.js' +import { dom } from './dom.js' const DEFAULT_DURATION = 4000 const MAX_TOASTS = 3 - -let container = null - -function getContainer() { - if (!container) container = document.getElementById('toastContainer') - return container -} +// as long as the toast-out animation of toast.css: a timer and not the end of the animation, which does not come when animations are off +const LEAVE_MS = 200 function closeToast(toast) { if (!toast.isConnected || toast.classList.contains('toast-leaving')) return setClass(toast, 'toast-entering', false) setClass(toast, 'toast-leaving') - - const handleAnimationEnd = (event) => { - if (event.animationName === 'toast-out') toast.remove() - } - - toast.addEventListener('animationend', handleAnimationEnd) - // animationend does not come when animations are off (reduced motion, a hidden tab): the toast is removed anyway - setTimeout(() => toast.remove(), 1000) + setTimeout(() => toast.remove(), LEAVE_MS) } -function enforceMaxToasts(root) { - const toasts = Array.from(root.querySelectorAll('.toast-wrapper')) +// the toasts that stay on the screen: one that is leaving is not counted +const staying = (root) => Array.from(root.querySelectorAll('.toast-wrapper:not(.toast-leaving)')) - while (toasts.length >= MAX_TOASTS) { - const oldestToast = toasts.shift() +function enforceMaxToasts(root) { + const toasts = staying(root) - if (oldestToast) closeToast(oldestToast) - } + while (toasts.length >= MAX_TOASTS) closeToast(toasts.shift()) } function isShowing(root, message) { - return Array.from(root.querySelectorAll('.toast-wrapper:not(.toast-leaving) .toast-message')).some((node) => node.textContent === message) + return staying(root).some((toast) => toast.querySelector('.toast-message').textContent === message) } export function showToast(message, options = {}) { - const root = getContainer() + const root = dom.toastContainer if (!root || !message) return () => {} const { type = 'info', duration = DEFAULT_DURATION } = options - if (type === 'error' && isShowing(root, message)) return () => {} + // the same message twice at once (a button pressed again, a save that is repeated) is one message + if (isShowing(root, message)) return () => {} enforceMaxToasts(root) diff --git a/public/js/control/twitch/twitch-chat-commands.js b/public/js/control/twitch/twitch-chat-commands.js index 990330d..00750fa 100644 --- a/public/js/control/twitch/twitch-chat-commands.js +++ b/public/js/control/twitch/twitch-chat-commands.js @@ -1,5 +1,4 @@ import { api } from '../api.js' -import { run } from '../run.js' import { state } from '../state.js' import { dom } from '../dom.js' import { fillForm, saveForm, toastSaveResult, trackForm } from '../form.js' @@ -17,18 +16,16 @@ export function cancelChatCommandChanges() { fillForm(dom.twitchChatCommandsPanel, state.twitch.config) } -export function saveTwitchChatCommands() { - return run('saving Twitch chat commands', async () => { - const result = await saveForm( - dom.twitchChatCommandsPanel, - async (body) => { - const answer = await api.updateTwitchConfig(body) - state.twitch.config = answer.config - return answer - }, - { changedOnly: true } - ) +export async function saveTwitchChatCommands() { + const result = await saveForm( + dom.twitchChatCommandsPanel, + async (body) => { + const answer = await api.updateTwitchConfig(body) + state.twitch.config = answer.config + return answer + }, + { changedOnly: true } + ) - toastSaveResult(result, 'toast.twitchChatCommandsSaved') - }) + toastSaveResult(result, 'toast.twitchChatCommandsSaved') } diff --git a/public/js/control/twitch/twitch-connection.js b/public/js/control/twitch/twitch-connection.js index 0142a4d..ad2c899 100644 --- a/public/js/control/twitch/twitch-connection.js +++ b/public/js/control/twitch/twitch-connection.js @@ -102,7 +102,7 @@ export function refreshTwitchStatus() { ) } -export function connectTwitch() { +export async function connectTwitch() { const pending = state.twitch.authorization if (pending && pending.expiresAt > Date.now() + EXPIRY_TOLERANCE_MS) { window.open(pending.verificationUri, '_blank', 'noopener,noreferrer') @@ -112,47 +112,43 @@ export function connectTwitch() { // opened up-front so the popup is attributed to the click and not to the API response const authWindow = openAuthorizationWindow() - return run('connecting Twitch', async () => { - let navigated = false + let navigated = false - try { - const response = await api.connectTwitch() - - if (!response?.verificationUri || !response?.userCode) { - throw new ApiError('Twitch authorization data is missing', { code: 'TWITCH_AUTH_ERROR' }) - } - - // From here the server keeps the state of the login (and says when it ends): this page does not wait for it, and - // after a reload the same code is still there, with the button leading back to its page - state.twitch.authorization = { - userCode: response.userCode, - verificationUri: response.verificationUri, - expiresAt: Date.now() + response.expiresIn * 1000 - } - renderTwitchConnection() + try { + const response = await api.connectTwitch() - navigateAuthorizationWindow(authWindow, response.verificationUri) - navigated = true - } finally { - // the popup was opened before the request, so it is closed on every path that never sent it to Twitch - if (!navigated) authWindow?.close() + if (!response?.verificationUri || !response?.userCode) { + throw new ApiError('Twitch authorization data is missing', { code: 'TWITCH_AUTH_ERROR' }) } - }) -} -export function disconnectTwitch() { - return run('disconnecting Twitch', async () => { - const result = await api.disconnectTwitch() - applyStatus(null) - state.twitch.rewards = [] - show(dom.twitchRewardForm, false) + // From here the server keeps the state of the login (and says when it ends): this page does not wait for it, and + // after a reload the same code is still there, with the button leading back to its page + state.twitch.authorization = { + userCode: response.userCode, + verificationUri: response.verificationUri, + expiresAt: Date.now() + response.expiresIn * 1000 + } renderTwitchConnection() - renderTwitchRewards() - // queued tracks paid with points stay UNFULFILLED on Twitch: nobody else will close them - const open = Number(result?.openRedemptions) || 0 - if (open > 0) toastInfo(t('toast.twitchDisconnectedWithOpenRewards', { count: open })) - }) + navigateAuthorizationWindow(authWindow, response.verificationUri) + navigated = true + } finally { + // the popup was opened before the request, so it is closed on every path that never sent it to Twitch + if (!navigated) authWindow?.close() + } +} + +export async function disconnectTwitch() { + const result = await api.disconnectTwitch() + applyStatus(null) + state.twitch.rewards = [] + show(dom.twitchRewardForm, false) + renderTwitchConnection() + renderTwitchRewards() + + // queued tracks paid with points stay UNFULFILLED on Twitch: nobody else will close them + const open = Number(result?.openRedemptions) || 0 + if (open > 0) toastInfo(t('toast.twitchDisconnectedWithOpenRewards', { count: open })) } export function loadTwitchSettings() { diff --git a/public/js/control/twitch/twitch-rewards.js b/public/js/control/twitch/twitch-rewards.js index eaef73c..0606b58 100644 --- a/public/js/control/twitch/twitch-rewards.js +++ b/public/js/control/twitch/twitch-rewards.js @@ -4,7 +4,6 @@ import { run } from '../run.js' import { state } from '../state.js' import { dom } from '../dom.js' import { fillForm, saveForm, toastSaveResult, trackForm } from '../form.js' -import { toastSuccess } from '../toast.js' import { showRewardForm, bindRewardForm } from './twitch-reward-form.js' function option(select, value, text, selected = false) { @@ -65,21 +64,21 @@ export function applyRewardConfig(config) { fillForm(dom.twitchRewardSection, config) } +function persistTwitchConfig() { + return saveForm( + dom.twitchRewardSection, + async (body) => { + const answer = await api.updateTwitchConfig(body) + state.twitch.config = answer.config + return answer + }, + { changedOnly: true } + ) +} + // the selected reward and the auto-fulfil switch are saved at once, whenever one of them changes export function saveTwitchConfig() { - return run('saving Twitch config', async () => { - const result = await saveForm( - dom.twitchRewardSection, - async (body) => { - const answer = await api.updateTwitchConfig(body) - state.twitch.config = answer.config - return answer - }, - { changedOnly: true } - ) - - toastSaveResult(result, 'toast.twitchSettingsSaved') - }) + return run('saving Twitch config', async () => toastSaveResult(await persistTwitchConfig(), 'toast.twitchSettingsSaved')) } // back to the reward as Twitch has it (or to a blank form while a new one is being created) @@ -87,26 +86,24 @@ export function cancelRewardChanges() { showRewardForm(state.twitch.editingReward) } -export function saveReward() { +export async function saveReward() { const editing = state.twitch.editingReward - return run(editing ? 'updating Twitch reward' : 'creating Twitch reward', async () => { - const result = await saveForm(dom.twitchRewardForm, (body) => (editing ? api.updateTwitchReward(editing.id, body) : api.createTwitchReward(body))) + const result = await saveForm(dom.twitchRewardForm, (body) => (editing ? api.updateTwitchReward(editing.id, body) : api.createTwitchReward(body))) - if (result.refused) return toastSaveResult(result, 'toast.twitchRewardUpdated') + if (result.refused) return toastSaveResult(result, 'toast.twitchRewardUpdated') - await readRewards() + await readRewards() - if (editing) { - state.twitch.editingReward = result.config - return toastSaveResult(result, 'toast.twitchRewardUpdated') - } + if (editing) { + state.twitch.editingReward = result.config + return toastSaveResult(result, 'toast.twitchRewardUpdated') + } - state.twitch.selectedRewardId = result.config.id - renderTwitchRewards() - await saveTwitchConfig() - toastSuccess(t('toast.twitchRewardCreated')) - }) + state.twitch.selectedRewardId = result.config.id + renderTwitchRewards() + // the new reward is the one for the song requests: one message for both, not one for each + toastSaveResult(await persistTwitchConfig(), 'toast.twitchRewardCreated') } export function bindTwitchRewardTracking() { diff --git a/public/js/control/ui.js b/public/js/control/ui.js index 6374d04..32ce33e 100644 --- a/public/js/control/ui.js +++ b/public/js/control/ui.js @@ -1,20 +1,21 @@ import { getCurrentLocale } from '../i18n.js' +const formatters = new Map() + +// a formatter is made once per language: a list of rows asks for hundreds of dates +function formatter(locale, kind, options) { + const id = `${locale}:${kind}` + if (!formatters.has(id)) formatters.set(id, new Intl.DateTimeFormat(locale, options)) + return formatters.get(id) +} + export function formatDateTime(timestamp, joiner = ', ') { if (!timestamp) return '-' // the order of day and month (and the digits) follow the language of the panel const locale = getCurrentLocale() - const time = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12: false }).format(timestamp) - const day = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit' }).format(timestamp) + const time = formatter(locale, 'time', { hour: '2-digit', minute: '2-digit', hour12: false }).format(timestamp) + const day = formatter(locale, 'day', { day: '2-digit', month: '2-digit' }).format(timestamp) return [time, day].join(joiner) } - -export function toggleActive(element, isActive) { - if (isActive) { - element.classList.add('active') - } else { - element.classList.remove('active') - } -} diff --git a/public/js/control/views/fallback.js b/public/js/control/views/fallback.js index 614fbcb..da7e87d 100644 --- a/public/js/control/views/fallback.js +++ b/public/js/control/views/fallback.js @@ -1,4 +1,4 @@ -import { displayChannel, displayTitle, formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, formatTrackDuration } from '../../shared.js' import { PLUS_ICON, PLAY_ICON } from '../../icons.js' import { t } from '../../i18n.js' import { dom } from '../dom.js' @@ -14,7 +14,7 @@ export const fallbackView = createListView(dom.fallbackListWrapper, { thumbnail: track.thumbnail, title: displayTitle(track.title), subtitle: displayChannel(track.channelTitle), - meta: formatDuration(track.duration), + meta: formatTrackDuration(track.duration), extra: track.isActive ? statusPill(t('fallback.playing'), 'info') : track.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : '', actions: track.isBlocked ? unblockTrackItem(track.videoId) diff --git a/public/js/control/views/queue.js b/public/js/control/views/queue.js index c992735..c8d777a 100644 --- a/public/js/control/views/queue.js +++ b/public/js/control/views/queue.js @@ -1,4 +1,4 @@ -import { displayChannel, displayTitle, escapeHtml, formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, escapeHtml, formatTrackDuration } from '../../shared.js' import { DELETE_ICON } from '../../icons.js' import { t, requesterName } from '../../i18n.js' import { dom } from '../dom.js' @@ -19,7 +19,7 @@ export const queueView = createListView(dom.queueListWrapper, { ${escapeHtml(requesterName(item))} </span> `, - meta: formatDuration(item.duration), + meta: formatTrackDuration(item.duration), actions: item.isBlocked ? unblockTrackItem(item.videoId) : rowMenu([ diff --git a/public/js/control/views/search.js b/public/js/control/views/search.js index 68e36af..27703f5 100644 --- a/public/js/control/views/search.js +++ b/public/js/control/views/search.js @@ -1,4 +1,4 @@ -import { displayChannel, displayTitle, escapeHtml, formatDuration } from '../../shared.js' +import { displayChannel, displayTitle, escapeHtml, formatTrackDuration } from '../../shared.js' import { PLUS_ICON } from '../../icons.js' import { t } from '../../i18n.js' import { dom } from '../dom.js' @@ -12,7 +12,7 @@ export const searchView = createListView(dom.searchListWrapper, { title: displayTitle(song.title), subtitle: displayChannel(song.channelTitle), extra: song.isBlocked ? statusPill(t('blocklist.blockedLabel'), 'rejected') : '', - meta: formatDuration(song.duration), + meta: formatTrackDuration(song.duration), actions: ` <button class="btn btn-sm btn-icon" diff --git a/public/js/sse.js b/public/js/events.js similarity index 50% rename from public/js/sse.js rename to public/js/events.js index 6019ade..486383b 100644 --- a/public/js/sse.js +++ b/public/js/events.js @@ -5,6 +5,33 @@ const DOWN_AFTER_MS = 10_000 const REOPEN_AFTER_MS = 3_000 const CLOSED = 2 +// named timers: setting one replaces the one that was set under the same name +function createTimers() { + const timers = new Map() + + return { + has: (name) => timers.has(name), + set(name, callback, ms) { + clearTimeout(timers.get(name)) + timers.set( + name, + setTimeout(() => { + timers.delete(name) + callback() + }, ms) + ) + }, + clear(name) { + clearTimeout(timers.get(name)) + timers.delete(name) + }, + clearAll() { + timers.forEach(clearTimeout) + timers.clear() + } + } +} + /** * Listens to /api/events: a frame only says "this topic changed, read it again", the data comes from the usual GET. * @@ -21,13 +48,10 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc } const url = `/api/events?topics=${topics.join(',')}` - const coalescing = new Map() + const timers = createTimers() let source = null let status = null let closed = false - let watchdogTimer = null - let downTimer = null - let reopenTimer = null function setStatus(next) { if (status === next) return @@ -36,34 +60,20 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc onStatus(next) } + // a topic that is already waiting for its turn is not asked for again function notify(topic) { - if (coalescing.has(topic)) return - - coalescing.set( - topic, - setTimeout(() => { - coalescing.delete(topic) - onChange(topic) - }, COALESCE_MS) - ) + const name = `change:${topic}` + if (!timers.has(name)) timers.set(name, () => onChange(topic), COALESCE_MS) } - function armWatchdog() { - clearTimeout(watchdogTimer) - watchdogTimer = setTimeout(reopen, WATCHDOG_MS) - } + const armWatchdog = () => timers.set('watchdog', reopen, WATCHDOG_MS) function armDownTimer() { - if (downTimer || status === 'down') return - - downTimer = setTimeout(() => { - downTimer = null - setStatus('down') - }, DOWN_AFTER_MS) + if (!timers.has('down') && status !== 'down') timers.set('down', () => setStatus('down'), DOWN_AFTER_MS) } function disposeSource() { - clearTimeout(watchdogTimer) + timers.clear('watchdog') source?.close() source = null } @@ -76,8 +86,7 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc function open() { if (closed) return - clearTimeout(reopenTimer) - reopenTimer = null + timers.clear('reopen') armDownTimer() const current = new EventSourceImpl(url) @@ -91,8 +100,7 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc } listen('open', () => { - clearTimeout(downTimer) - downTimer = null + timers.clear('down') setStatus('open') armWatchdog() topics.forEach(notify) @@ -130,7 +138,7 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc // the browser reconnects by itself, unless it gave up (an error answer such as 503): then it is done here if (current.readyState === CLOSED) { disposeSource() - reopenTimer ??= setTimeout(open, REOPEN_AFTER_MS) + if (!timers.has('reopen')) timers.set('reopen', open, REOPEN_AFTER_MS) } }) } @@ -141,81 +149,7 @@ export function connectEvents({ topics, onChange, onStatus, onNotice, EventSourc close() { closed = true disposeSource() - clearTimeout(downTimer) - clearTimeout(reopenTimer) - coalescing.forEach(clearTimeout) - coalescing.clear() - } - } -} - -// Runs the task one at a time. A request that comes while it is running makes it run once more afterwards, -// so the last thing shown is always the answer to the last request, never an older one that arrived late -export function serialized(task) { - let running = false - let again = false - - return async () => { - if (running) { - again = true - return - } - - running = true - try { - do { - again = false - await task() - } while (again) - } finally { - running = false - } - } -} - -/** - * Keeps a page up to date with the server. `topics` is { name: { read, isShown } }: what each topic reads again and, optionally, - * whether anyone looks at it now (a topic that is not shown is not read; whoever shows it again reads it). - * - * - a topic is read one at a time, and a change that is announced while it is read makes it read once more (see serialized) - * - everything is read when the connection comes up: what changed while it was down was never announced - * - while the connection is down the page reads everything every `safetyMs`, as it did before there were events - * - * Returns refresh(name), refreshAll(), isOpen() and close(). - */ -export function watch(topics, { onNotice, safetyMs = 10_000, EventSourceImpl } = {}) { - const names = Object.keys(topics) - const reads = Object.fromEntries(names.map((name) => [name, serialized(topics[name].read)])) - let open = false - let safetyNet = null - - const refresh = (name) => (topics[name].isShown?.() ?? true ? reads[name]() : undefined) - const refreshAll = () => names.forEach(refresh) - - const events = connectEvents({ - topics: names, - onChange: refresh, - onNotice, - onStatus: (status) => { - open = status === 'open' - - if (open) { - clearInterval(safetyNet) - safetyNet = null - } else { - safetyNet ??= setInterval(refreshAll, safetyMs) - } - }, - EventSourceImpl - }) - - return { - refresh, - refreshAll, - isOpen: () => open, - close() { - events.close() - clearInterval(safetyNet) + timers.clearAll() } } } diff --git a/public/js/http.js b/public/js/http.js new file mode 100644 index 0000000..c78d2e4 --- /dev/null +++ b/public/js/http.js @@ -0,0 +1,57 @@ +export class ApiError extends Error { + constructor(message, { status, code, params, cause } = {}) { + super(message, { cause }) + this.name = 'ApiError' + this.status = status + this.code = code + this.params = params + } +} + +// a request that gets no answer for this long is a failed one: a read that never ends would also stop its topic from ever being read again +const REQUEST_TIMEOUT_MS = 15_000 + +/** + * How the pages (the panel and the overlay) talk to the server: the `data` of the reply, or an ApiError. + * The error has the `status`, `code` and `params` the server gave, and no status at all when the server could not be reached. + * + * `partial`: the endpoint answers a save with some refused fields as INVALID_CONFIG plus the stored config and the list of refused fields, + * which is a result to show and not a failure + */ +export async function request(url, options = {}, { partial = false } = {}) { + let response + + try { + response = await fetch(url, { + ...options, + signal: options.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + ...(options.body !== undefined && { 'Content-Type': 'application/json' }), + ...options.headers + } + }) + } catch (cause) { + // fetch rejects only when the server cannot be reached at all + throw new ApiError('Network error', { code: 'NETWORK_ERROR', cause }) + } + + let body = null + + try { + body = await response.json() + } catch { + // Empty/non-JSON response + } + + if (!response.ok) { + if (partial && body?.code === 'INVALID_CONFIG' && body?.data) return body.data + + throw new ApiError(body?.error || `Request failed: ${response.status}`, { + status: response.status, + code: body?.code, + params: body?.params + }) + } + + return body?.data ?? null +} diff --git a/public/js/i18n.js b/public/js/i18n.js index 9f666e2..af151db 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -47,13 +47,8 @@ export function t(key, params = {}) { if (value === undefined) return key - let result = value - - for (const [param, replacement] of Object.entries(params)) { - result = result.replaceAll(`{{${param}}}`, () => String(replacement)) - } - - return result + // one pass: a value that itself contains "{{other}}" (a track title is typed by a viewer) is not looked into again + return value.replace(/\{\{(\w+)\}\}/g, (placeholder, name) => (name in params ? String(params[name]) : placeholder)) } export function getCurrentLocale() { diff --git a/public/js/overlay.js b/public/js/overlay.js index 1a4e171..5905cfa 100644 --- a/public/js/overlay.js +++ b/public/js/overlay.js @@ -1,8 +1,9 @@ -import { $, createLogger, displayTitle, formatDuration, getErrorMessage, show, setClass, setText } from './shared.js' -import { initI18n, t, getCurrentLocale, requesterName } from './i18n.js' +import { $, createLogger, displayTitle, formatDuration, formatTrackDuration, show, setClass, setText } from './shared.js' +import { initI18n, getCurrentLocale, requesterName } from './i18n.js' import { loadYouTubeApi } from './youtube-api.js' import { claimPlayback } from './playback-lock.js' -import { watch } from './sse.js' +import { watch } from './watch.js' +import { request } from './http.js' let player = null let currentState = null @@ -22,13 +23,16 @@ const log = createLogger('PREVIEW') // only one overlay on this computer plays the music: the others (and any overlay opened by another address) just show the badge let isPlaybackSource = false -const REQUEST_TIMEOUT_MS = 8000 - // The YouTube player reports error 5 for a problem of the player itself (not of the video); a retry usually fixes it const TEMPORARY_PLAYER_ERROR = 5 const MAX_TEMPORARY_ERROR_RETRIES = 2 const temporaryErrorRetries = new Map() +// A track that does not start (no network in OBS, an autoplay that is blocked: the player says nothing then) would hold the queue for ever. +// Not playing after this long counts as a temporary player error: retried, and reported when the retries are used up +const START_TIMEOUT_MS = 25_000 +let startWatchdog = null + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) // the elements of overlay.html, found when the overlay starts @@ -36,24 +40,44 @@ let dom = null const readState = () => watcher.refresh('state') +function clearStartWatchdog() { + clearTimeout(startWatchdog) + startWatchdog = null +} + +function armStartWatchdog(videoId) { + clearStartWatchdog() + const watched = player + + startWatchdog = setTimeout(() => { + startWatchdog = null + if (player !== watched || currentState?.current?.videoId !== videoId || currentState.isPaused) return + + log(`${videoId} did not start playing in ${START_TIMEOUT_MS / 1000} s`) + onPlayerError({ data: TEMPORARY_PLAYER_ERROR, target: watched }) + }, START_TIMEOUT_MS) +} + async function fetchOverlayState() { - try { - const response = await fetch('/api/overlay-state', { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - if (!response.ok) throw new Error(`overlay-state request failed: ${response.status}`) - - const body = await response.json() - settings = body.data.settings - currentState = body.data.state - const serverLocale = settings.locale || 'en' - if (!localeLoaded || serverLocale !== getCurrentLocale()) { - await initI18n(serverLocale) - localeLoaded = true - } + let data - renderState() + try { + data = await request('/api/overlay-state') } catch (error) { - log('Error fetching settings:', error) + log('Reading the state failed:', error) + return } + + settings = data.settings + currentState = data.state + const serverLocale = settings.locale || 'en' + if (!localeLoaded || serverLocale !== getCurrentLocale()) { + await initI18n(serverLocale) + localeLoaded = true + } + + // a fault of the drawing is not one of the network: it is not hidden behind the message of a failed read (the watcher logs it) + renderState() } function updateMediaVisibility() { @@ -81,7 +105,7 @@ function renderCurrent() { if (currentState.nextTrack) { setText(dom.nextTitle, displayTitle(currentState.nextTrack.title)) - setText(dom.nextElapsedTime, formatDuration(currentState.nextTrack.duration)) + setText(dom.nextElapsedTime, formatTrackDuration(currentState.nextTrack.duration)) show(dom.nextPlaying) setClass(dom.currentTitle, 'truncate-2line', false) } else { @@ -100,6 +124,7 @@ function renderState() { if (!currentState.current) { temporaryErrorRetries.clear() loadedPlayId = null + clearStartWatchdog() player.stopVideo() return } @@ -114,10 +139,12 @@ function renderState() { if (currentState.isPaused) { log(`Cueing video while paused: ${videoId}`) + clearStartWatchdog() player.cueVideoById(videoId) } else { log(`Loading video: ${videoId}`) player.loadVideoById(videoId) + armStartWatchdog(videoId) } loadedPlayId = playId @@ -126,6 +153,8 @@ function renderState() { } if (currentState.isPaused) { + clearStartWatchdog() + if (playerState === YT.PlayerState.PLAYING || playerState === YT.PlayerState.BUFFERING) { log('Pausing video') player.pauseVideo() @@ -136,6 +165,7 @@ function renderState() { if (playerState === YT.PlayerState.PAUSED || playerState === YT.PlayerState.CUED) { log('Resuming video') player.playVideo() + if (playerState === YT.PlayerState.CUED) armStartWatchdog(videoId) } } @@ -157,14 +187,17 @@ function updateProgress() { const currentTime = player.getCurrentTime() || 0 const duration = currentState.current.duration + // a live stream has no length to count the progress against + const hasLength = duration > 0 - setText(dom.elapsedTimeCurrent, formatDuration(currentTime, false)) + setText(dom.elapsedTimeCurrent, formatDuration(currentTime)) - if (currentTime > 0 || duration > 0) { - setText(dom.elapsedTimeEnd, `${duration <= 0 || settings.showVideo ? '/ ' : ''}${formatDuration(duration)}`) + if (currentTime > 0 || hasLength) { + const slash = !hasLength || settings.showVideo ? '/ ' : '' + setText(dom.elapsedTimeEnd, `${slash}${formatTrackDuration(duration)}`) - dom.progressBar.style.setProperty('--progress', `${Math.floor((currentTime / duration) * 100)}%`) - show(dom.progressBar, duration > 0) + if (hasLength) dom.progressBar.style.setProperty('--progress', `${Math.min(100, Math.floor((currentTime / duration) * 100))}%`) + show(dom.progressBar, hasLength) show(dom.elapsedTimeEnd) } else { show(dom.progressBar, false) @@ -178,24 +211,15 @@ function updateProgress() { async function postWithRetry(url, body) { for (let attempt = 0; ; attempt++) { try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) - }) - - if (response.ok) return true - - if (response.status < 500 && response.status !== 429) { - log(`${url} was refused with ${response.status}, not retrying`) + await request(url, { method: 'POST', body: JSON.stringify(body) }) + return true + } catch (error) { + // no status: the server could not be reached + if (error.status < 500 && error.status !== 429) { + log(`${url} was refused with ${error.status}, not retrying`) return false } - log(`${url} failed with ${response.status}`) - } catch (error) { log(`${url} failed:`, error) } @@ -234,8 +258,12 @@ function retryTemporaryError(errorCode, videoId, retried) { const stillCurrent = player === retried && currentState?.current?.videoId === videoId if (!stillCurrent) return - if (currentState.isPaused) player.cueVideoById({ videoId, startSeconds }) - else player.loadVideoById({ videoId, startSeconds }) + if (currentState.isPaused) { + player.cueVideoById({ videoId, startSeconds }) + } else { + player.loadVideoById({ videoId, startSeconds }) + armStartWatchdog(videoId) + } }, 2000 * (retries + 1) ) @@ -254,6 +282,8 @@ function onPlayerReady(event) { function onPlayerStateChange(event) { log(`Player state: ${event.data}`) + if (event.data === YT.PlayerState.PLAYING) clearStartWatchdog() + if (event.data === YT.PlayerState.ENDED && !isTransitioning) { log('Video ended, requesting next') isTransitioning = true @@ -266,8 +296,9 @@ function onPlayerStateChange(event) { } function onPlayerError(event) { - const message = getErrorMessage(event.data, t) - log(`Player error: ${message}`) + // an error answers the wait: a retry waits again, and a failure is reported + clearStartWatchdog() + log(`Player error: ${event.data}`) const videoId = currentState?.current?.videoId if (!videoId) { @@ -327,6 +358,7 @@ function createPlayer() { } async function resetPlayer() { + clearStartWatchdog() const oldPlayer = player player = null @@ -360,6 +392,7 @@ function enablePlayback() { log('This overlay plays the music') isPlaybackSource = true dom.nowPlayingVideo.classList.remove('hidden') + show(dom.progressWrapper) loadYouTubeApi().then(createPlayer) } @@ -379,6 +412,7 @@ export function startOverlay({ locks } = {}) { currentThumbnail: $('currentThumbnail'), currentTitle: $('currentTitle'), currentRequester: $('currentRequester'), + progressWrapper: $('progressWrapper'), progressBar: $('progressBar'), elapsedTimeCurrent: $('elapsedTimeCurrent'), elapsedTimeEnd: $('elapsedTimeEnd'), @@ -391,6 +425,8 @@ export function startOverlay({ locks } = {}) { // hidden until the lock is granted: a waiting overlay must not show an empty video frame dom.nowPlayingVideo.classList.add('hidden') + // the time comes from the player of this page: an overlay without one would show 0:00 for ever + show(dom.progressWrapper, false) if (isLocalhost) claimPlayback(enablePlayback, locks) else log('Non-localhost origin: read-only widget, no embedded player') @@ -405,6 +441,7 @@ export function startOverlay({ locks } = {}) { refresh: readState, stop() { clearInterval(timer) + clearStartWatchdog() watcher.close() } } diff --git a/public/js/playback-lock.js b/public/js/playback-lock.js index 921415a..5a32c76 100644 --- a/public/js/playback-lock.js +++ b/public/js/playback-lock.js @@ -10,9 +10,11 @@ export function claimPlayback(onGranted, locks = globalThis.navigator?.locks) { return } - // never settles: the lock is held until the page is closed - locks.request(LOCK_NAME, () => { - onGranted() - return new Promise(() => {}) - }) + // never settles: the lock is held until the page is closed. A page that is not allowed to ask for it plays, as without locks + Promise.resolve( + locks.request(LOCK_NAME, () => { + onGranted() + return new Promise(() => {}) + }) + ).catch(() => onGranted()) } diff --git a/public/js/serialized.js b/public/js/serialized.js new file mode 100644 index 0000000..96c8ef9 --- /dev/null +++ b/public/js/serialized.js @@ -0,0 +1,38 @@ +// Runs the task one at a time. A request that comes while it is running makes it run once more afterwards, +// so the last thing shown is always the answer to the last request, never an older one that arrived late. +// Every caller gets the promise of the run that is going on. A failed run does not drop a request that came during it: +// the task runs again, and the promise is rejected only if the last run failed +export function serialized(task) { + let current = null + let again = false + + return () => { + if (current) { + again = true + return current + } + + current = (async () => { + try { + let failure = null + + do { + again = false + + try { + await task() + failure = null + } catch (error) { + failure = { error } + } + } while (again) + + if (failure) throw failure.error + } finally { + current = null + } + })() + + return current + } +} diff --git a/public/js/shared.js b/public/js/shared.js index f110043..1428f51 100644 --- a/public/js/shared.js +++ b/public/js/shared.js @@ -16,14 +16,18 @@ export function escapeHtml(value) { ) } -export function formatDuration(duration, isNullable = true) { - if (isNullable) { - if (!duration || duration <= 0) return 'LIVE' - } +export function formatDuration(seconds) { + const total = Math.max(0, Math.floor(seconds) || 0) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const secs = String(total % 60).padStart(2, '0') + + return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${secs}` : `${minutes}:${secs}` +} - const mins = Math.floor(duration / 60) - const secs = Math.floor(duration) % 60 - return `${mins}:${secs.toString().padStart(2, '0')}` +// the length of a track: one without a length is a live stream +export function formatTrackDuration(seconds) { + return seconds > 0 ? formatDuration(seconds) : t('common.live') } export function youtubeThumbnail(videoId, quality = 'mqdefault') { @@ -43,28 +47,6 @@ export function createLogger(prefix) { } } -const errorMessages = { - 2: 'player.error.invalidParameter', - 5: 'player.error.html5Error', - 100: 'player.error.videoNotFound', - 101: 'player.error.embedNotAllowed', - 150: 'player.error.embedNotAllowed' -} - -export function getErrorMessage(code, t) { - const key = errorMessages[code] - if (key && t) { - return t(key, { code }) - } - if (key) { - return key - } - if (t) { - return t('player.error.errorCode', { code }) - } - return `Error code ${code}` -} - function codeToI18nKey(code) { const camel = code.toLowerCase().replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) return `api.errors.${camel}` diff --git a/public/js/watch.js b/public/js/watch.js new file mode 100644 index 0000000..b13817c --- /dev/null +++ b/public/js/watch.js @@ -0,0 +1,62 @@ +import { connectEvents } from './events.js' +import { createLogger } from './shared.js' +import { serialized } from './serialized.js' + +const log = createLogger('WATCH') + +/** + * Keeps a page up to date with the server. `topics` is { name: { read, isShown } }: what each topic reads again and, optionally, + * whether anyone looks at it now (a topic that is not shown is not read; whoever shows it again reads it). + * + * - a topic is read one at a time, and a change that is announced while it is read makes it read once more (see serialized) + * - everything is read when the connection comes up: what changed while it was down was never announced + * - while the connection is down the page reads everything every `safetyMs`, as it did before there were events + * + * Returns refresh(name), refreshAll(), isOpen() and close(). + */ +export function watch(topics, { onNotice, safetyMs = 10_000, EventSourceImpl } = {}) { + const names = Object.keys(topics) + // a read that fails is logged here: nobody awaits what an event or a timer starts, and a rejection would go unhandled + const guarded = (name) => async () => { + try { + await topics[name].read() + } catch (error) { + log(`Reading "${name}" failed:`, error) + } + } + + const reads = Object.fromEntries(names.map((name) => [name, serialized(guarded(name))])) + let open = false + let safetyNet = null + + const isShown = (name) => topics[name].isShown?.() ?? true + const refresh = (name) => (isShown(name) ? reads[name]() : undefined) + const refreshAll = () => Promise.all(names.map((name) => refresh(name))) + + const events = connectEvents({ + topics: names, + onChange: refresh, + onNotice, + onStatus: (status) => { + open = status === 'open' + + if (open) { + clearInterval(safetyNet) + safetyNet = null + } else { + safetyNet ??= setInterval(refreshAll, safetyMs) + } + }, + EventSourceImpl + }) + + return { + refresh, + refreshAll, + isOpen: () => open, + close() { + events.close() + clearInterval(safetyNet) + } + } +} diff --git a/public/js/youtube-api.js b/public/js/youtube-api.js index 1807a25..93ed57b 100644 --- a/public/js/youtube-api.js +++ b/public/js/youtube-api.js @@ -13,7 +13,7 @@ function addScript(attempt) { tag.onerror = () => { tag.remove() - const delay = Math.min(2000 * 2 ** Math.min(attempt, 5), 30000) + const delay = Math.min(2000 * 2 ** attempt, 30000) log(`API failed to load, retrying in ${delay / 1000}s`) setTimeout(() => addScript(attempt + 1), delay) } diff --git a/public/locales/en.json b/public/locales/en.json index cd16182..28b7c0d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -45,6 +45,7 @@ "on": "On", "off": "Off", "more": "More actions", + "live": "LIVE", "unknownTitle": "Unknown title", "unknownChannel": "Unknown channel" }, @@ -221,14 +222,7 @@ } }, "player": { - "views": "{{count}} views", - "error": { - "invalidParameter": "Invalid parameter", - "html5Error": "HTML5 player error", - "videoNotFound": "Video not found", - "embedNotAllowed": "Embed not allowed", - "errorCode": "Error code {{code}}" - } + "views": "{{count}} views" }, "api": { "errors": { @@ -306,6 +300,8 @@ "twitchChatCommandsSaved": "Chat commands saved", "twitchRewardCreated": "Reward created successfully", "twitchRewardUpdated": "Reward updated successfully", - "twitchDisconnectedWithOpenRewards": "Disconnected. Rewards still waiting in the Twitch rewards queue (fulfill or refund them there): {{count}}" + "twitchDisconnectedWithOpenRewards": "Disconnected. Rewards still waiting in the Twitch rewards queue (fulfill or refund them there): {{count}}", + "copied": "Copied", + "copyFailed": "Could not copy: select the link and copy it by hand" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 89ffa42..2babb02 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -45,6 +45,7 @@ "on": "Вкл", "off": "Выкл", "more": "Ещё действия", + "live": "LIVE", "unknownTitle": "Неизвестное название", "unknownChannel": "Неизвестный канал" }, @@ -221,14 +222,7 @@ } }, "player": { - "views": "{{count}} просмотров", - "error": { - "invalidParameter": "Неверный параметр", - "html5Error": "Ошибка HTML5 плеера", - "videoNotFound": "Видео не найдено", - "embedNotAllowed": "Встраивание не разрешено", - "errorCode": "Код ошибки {{code}}" - } + "views": "{{count}} просмотров" }, "api": { "errors": { @@ -298,7 +292,7 @@ "trackUnblocked": "Трек разблокирован", "settingsSaved": "Настройки сохранены", "settingsPartiallySaved": "Сохранено {{saved}}, не сохранено {{rejected}} (некорректные значения)", - "apiKeyNotSaved": "Config saved, but API key was not", + "apiKeyNotSaved": "Настройки сохранены, а API ключ нет", "nowPlaying": "Сейчас играет: {{title}}", "addedToQueue": "Добавлено в очередь: {{title}} [#{{position}}]", "viewerRequest": "🎵 {{user}} добавил(а): {{title}}", @@ -306,6 +300,8 @@ "twitchChatCommandsSaved": "Команды чата сохранены", "twitchRewardCreated": "Награда успешно создана", "twitchRewardUpdated": "Награда успешно обновлена", - "twitchDisconnectedWithOpenRewards": "Отключено. Наград, ожидающих в очереди наград Twitch (примите или верните их там): {{count}}" + "twitchDisconnectedWithOpenRewards": "Отключено. Наград, ожидающих в очереди наград Twitch (примите или верните их там): {{count}}", + "copied": "Скопировано", + "copyFailed": "Не удалось скопировать: выделите ссылку и скопируйте вручную" } } diff --git a/public/overlay.html b/public/overlay.html index 6eca73b..f5684db 100644 --- a/public/overlay.html +++ b/public/overlay.html @@ -18,7 +18,7 @@ <div class="now-playing"> <div class="title text-sm truncate" id="currentTitle"></div> <div class="text-xs text-green truncate" id="currentRequester"></div> - <div class="progress-wrapper"> + <div class="progress-wrapper" id="progressWrapper"> <div class="time text-xs text-secondary" id="elapsedTimeCurrent">0:00</div> <div class="progress hidden" id="progressBar"></div> <div class="time text-xs text-secondary hidden" id="elapsedTimeEnd">0:00</div> diff --git a/tests/frontend/control/actions.test.js b/tests/frontend/control/actions.test.js index 822c4d4..4769935 100644 --- a/tests/frontend/control/actions.test.js +++ b/tests/frontend/control/actions.test.js @@ -96,6 +96,34 @@ test('dispatchAction', async (t) => { assert.equal(button.disabled, false) }) + await t.test('an action that fails is reported by the dispatcher, and the button is usable again', async () => { + panel.route('POST /api/player/skip', [500, { error: 'raw', code: 'SERVER_ERROR' }]) + document.getElementById('toastContainer').innerHTML = '' + const button = document.createElement('button') + button.dataset.action = 'skip' + + await dispatchAction(button, clickEvent()) + await flush() + + assert.ok(panel.toasts().includes('Internal server error')) + assert.equal(button.disabled, false) + panel.route('POST /api/player/skip', {}) + }) + + await t.test('an action that throws at once is reported too, as something that went wrong', async () => { + ACTIONS.explode = () => { + throw new Error('boom') + } + document.getElementById('toastContainer').innerHTML = '' + const button = document.createElement('button') + button.dataset.action = 'explode' + + await dispatchAction(button, clickEvent()) + + assert.ok(panel.toasts().includes('Something went wrong, see the log for details')) + delete ACTIONS.explode + }) + await t.test('an action nobody handles is logged and does nothing', () => { const button = document.createElement('button') button.dataset.action = 'no-such-action' diff --git a/tests/frontend/control/api.test.js b/tests/frontend/control/api.test.js index 9f4b8ac..971fda0 100644 --- a/tests/frontend/control/api.test.js +++ b/tests/frontend/control/api.test.js @@ -6,7 +6,10 @@ const { api, ApiError } = await import('../../../public/js/control/api.js') const calls = [] let reply = null +let lastOptions = null + globalThis.fetch = async (url, options = {}) => { + lastOptions = options calls.push({ url, method: options.method ?? 'GET' }) if (reply instanceof Error) throw reply @@ -94,3 +97,24 @@ test('api', async (t) => { assert.deepEqual(calls, [{ url: '/api/queue/video/a%2Fb%20c', method: 'DELETE' }]) }) }) + +test('every request has a timeout, so a request that gets no answer fails instead of holding its topic for ever', async () => { + answer(200, { data: {} }) + await api.getSettings() + + assert.ok(lastOptions.signal instanceof AbortSignal) +}) + +test('a request has a JSON content type only when it has a body', async () => { + answer(200, { data: {} }) + await api.getSettings() + assert.equal(lastOptions.headers['Content-Type'], undefined) + + answer(200, { data: {} }) + await api.skip() + assert.equal(lastOptions.headers['Content-Type'], undefined) + + answer(200, { data: {} }) + await api.updateSettings({ locale: 'en' }) + assert.equal(lastOptions.headers['Content-Type'], 'application/json') +}) diff --git a/tests/frontend/control/blocklist.test.js b/tests/frontend/control/blocklist.test.js index bec3181..5210f1d 100644 --- a/tests/frontend/control/blocklist.test.js +++ b/tests/frontend/control/blocklist.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, V } from './panel-env.js' +import { setupPanel, V, act } from './panel-env.js' const panel = setupPanel() @@ -65,7 +65,7 @@ test('blocking and unblocking', async (t) => { panel.route('POST /api/blocklist', [400, { error: 'raw', code: 'INVALID_VIDEO_ID' }]) document.getElementById('toastContainer').innerHTML = '' - await blockTrack('nope', 'x') + await act(blockTrack, 'nope', 'x') assert.equal(panel.toasts().includes('Track blocked'), false) assert.ok(panel.toasts().includes('Invalid video ID')) diff --git a/tests/frontend/control/events.test.js b/tests/frontend/control/events.test.js index 8a53fd2..dbe843c 100644 --- a/tests/frontend/control/events.test.js +++ b/tests/frontend/control/events.test.js @@ -174,18 +174,6 @@ test('the settings inputs', async (t) => { assert.deepEqual(panel.calls, []) }) - await t.test('typing into a field takes the red mark off it, and so does choosing a value', () => { - // the API key is not a tracked bot setting: only this handler can clear its mark - const input = panel.$('#secYoutubeKey') - - for (const type of ['input', 'change']) { - input.classList.add('error') - fire(input, type) - - assert.equal(input.classList.contains('error'), false, type) - } - }) - await t.test('a bot setting is yellow while it differs from the stored one, and plain again when it matches', () => { fillForm(panel.$('#botSettingsPanel'), { minViews: 10 }) const input = panel.$('#cfgMinViews') diff --git a/tests/frontend/control/fallback-actions.test.js b/tests/frontend/control/fallback-actions.test.js index dfe90ea..6718fea 100644 --- a/tests/frontend/control/fallback-actions.test.js +++ b/tests/frontend/control/fallback-actions.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, makeTrack as track, V } from './panel-env.js' +import { setupPanel, makeTrack as track, V, act } from './panel-env.js' const panel = setupPanel() @@ -38,7 +38,7 @@ test('the switches of the playlist', async (t) => { state.fallback = fallback() panel.route('POST /api/fallback/shuffle', [500, { error: 'raw', code: 'SERVER_ERROR' }]) - await actions.toggleFallbackShuffle() + await act(actions.toggleFallbackShuffle) assert.equal(state.fallback.shuffle, false) assert.ok(panel.toasts().includes('Internal server error')) diff --git a/tests/frontend/control/fallback.test.js b/tests/frontend/control/fallback.test.js new file mode 100644 index 0000000..038ed72 --- /dev/null +++ b/tests/frontend/control/fallback.test.js @@ -0,0 +1,73 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setupPanel, makeTrack as track, V } from './panel-env.js' + +setupPanel() + +let scrolls = 0 +window.HTMLElement.prototype.scrollBy = () => { + scrolls++ +} + +const { state } = await import('../../../public/js/control/state.js') +const { renderFallback } = await import('../../../public/js/control/fallback.js') +const { switchSection } = await import('../../../public/js/control/tabs.js') + +// the row that is active is the one the player plays, when that is a track of the playlist +const play = (videoId) => { + state.current = videoId ? { ...track(videoId.slice(0, 1)), videoId, isFallback: true } : null +} + +const playlist = (upNext = [track('a'), track('b')]) => ({ + upNext, + enabled: true, + shuffle: false, + repeat: false, + lastRefreshedAt: 0, + sourceCount: upNext.length +}) + +test('the playlist follows the track that plays', async (t) => { + await t.test('once per track: a refresh that changes nothing does not pull the list back', () => { + scrolls = 0 + state.fallback = playlist() + play(V('a')) + + renderFallback() + renderFallback() + renderFallback() + assert.equal(scrolls, 1) + + play(V('b')) + renderFallback() + renderFallback() + assert.equal(scrolls, 2) + }) + + await t.test('and again when the list was empty in between', () => { + scrolls = 0 + play(V('b')) + state.fallback = playlist([]) + renderFallback() + state.fallback = playlist() + renderFallback() + + assert.equal(scrolls, 1) + }) +}) + +test('the sections of the dashboard', async (t) => { + await t.test('the list is scrolled when the playlist is shown, not when another section is', () => { + state.fallback = playlist() + play(V('a')) + scrolls = 0 + + switchSection('playlist') + assert.equal(scrolls, 1) + + switchSection('activity') + switchSection('blocklist') + switchSection('queue') + assert.equal(scrolls, 1) + }) +}) diff --git a/tests/frontend/control/form.test.js b/tests/frontend/control/form.test.js index b2143eb..8c31802 100644 --- a/tests/frontend/control/form.test.js +++ b/tests/frontend/control/form.test.js @@ -274,6 +274,20 @@ test('saveForm', async (t) => { assert.equal(isChanged(input('views')), true) }) + await t.test('what is typed while the request is on its way stays as typed, and still counts as a change', async () => { + fillForm(form, base) + input('views').value = '25' + + await saveForm(form, async () => { + input('views').value = '30' + return { config: { ...base, minViews: 25 }, rejected: [] } + }) + + assert.equal(input('views').value, '30') + assert.equal(isChanged(input('views')), true) + assert.deepEqual(marks('views'), ['changed']) + }) + await t.test('with changedOnly sends only what was changed, and still shows what the server stores for the rest', async () => { fillForm(form, base) input('views').value = '25' diff --git a/tests/frontend/control/panel-env.js b/tests/frontend/control/panel-env.js index fc2dc5a..6a3e1cb 100644 --- a/tests/frontend/control/panel-env.js +++ b/tests/frontend/control/panel-env.js @@ -178,3 +178,9 @@ export function installYouTube() { return { players, PlayerState } } + +// What the dispatcher of the panel does around an action: a failure is logged and shown, so the tests of an action call it this way +export async function act(handler, ...args) { + const { run } = await import('../../../public/js/control/run.js') + return run('test', () => handler(...args)) +} diff --git a/tests/frontend/control/player-actions.test.js b/tests/frontend/control/player-actions.test.js index 827511c..5a8a631 100644 --- a/tests/frontend/control/player-actions.test.js +++ b/tests/frontend/control/player-actions.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, deferred, makeTrack as track } from './panel-env.js' +import { setupPanel, deferred, makeTrack as track, act } from './panel-env.js' const panel = setupPanel() @@ -55,7 +55,7 @@ test('play and pause', async (t) => { state.isPaused = false panel.calls.length = 0 - await playPauseCurrent() + await act(playPauseCurrent) assert.deepEqual(panel.calls, ['POST /api/player/pause']) assert.ok(panel.toasts().includes('Internal server error')) diff --git a/tests/frontend/control/queue.test.js b/tests/frontend/control/queue.test.js index 9fabf3c..2a3503f 100644 --- a/tests/frontend/control/queue.test.js +++ b/tests/frontend/control/queue.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, deferred, makeTrack as track, V } from './panel-env.js' +import { setupPanel, deferred, makeTrack as track, V, act } from './panel-env.js' const panel = setupPanel() @@ -114,7 +114,7 @@ test('removeFromQueue', async (t) => { panel.route(`DELETE /api/queue/video/${V('y')}`, [404, { error: 'raw', code: 'QUEUE_ITEM_NOT_FOUND' }]) panel.calls.length = 0 - await queue.removeFromQueue(V('y')) + await act(queue.removeFromQueue, V('y')) assert.deepEqual(panel.calls, [`DELETE /api/queue/video/${V('y')}`]) assert.ok(panel.toasts().includes('Queue item not found')) diff --git a/tests/frontend/control/run.test.js b/tests/frontend/control/run.test.js index 9555895..db96c42 100644 --- a/tests/frontend/control/run.test.js +++ b/tests/frontend/control/run.test.js @@ -56,16 +56,4 @@ test('run', async (t) => { }) assert.equal(result, undefined) }) - - await t.test('onError is called with the thrown error', async () => { - const seen = [] - await run( - 'doing a thing', - () => { - throw new Error('boom') - }, - { silent: true, onError: (error) => seen.push(error.message) } - ) - assert.deepEqual(seen, ['boom']) - }) }) diff --git a/tests/frontend/control/search.test.js b/tests/frontend/control/search.test.js index 0865d81..846d076 100644 --- a/tests/frontend/control/search.test.js +++ b/tests/frontend/control/search.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { setupPanel, makeTrack as track, V } from './panel-env.js' +import { setupPanel, makeTrack as track, V, deferred, act } from './panel-env.js' const panel = setupPanel() @@ -52,8 +52,8 @@ test('search', async (t) => { panel.calls.length = 0 input.value = 'broken' - await search() - await search() + await act(search) + await act(search) assert.equal(panel.count('GET /api/search?q=broken&admin=1'), 2) assert.ok(panel.toasts().includes('Internal server error')) @@ -78,6 +78,71 @@ test('adding a track', async (t) => { assert.ok(panel.toasts().includes('Added to queue: Song [#3]')) }) + await t.test('a link without https:// is a link too', async () => { + input.value = 'youtu.be/dQw4w9WgXcQ' + panel.calls.length = 0 + + await search() + + assert.deepEqual(panel.calls, ['POST /api/queue/request']) + }) + + await t.test('words that only mention a link, or a link to another site, are a search', async () => { + for (const query of ['lofi youtube.com/ mix', 'https://evil.example/?u=youtube.com/x', 'youtube.com']) { + panel.route(`GET /api/search?q=${encodeURIComponent(query)}&admin=1`, { results: [] }) + input.value = query + panel.calls.length = 0 + + await search() + + assert.deepEqual(panel.calls, [`GET /api/search?q=${encodeURIComponent(query)}&admin=1`], query) + } + }) + + await t.test('the box is emptied once a link was queued, and what was typed meanwhile is left alone', async () => { + input.value = 'https://youtu.be/dQw4w9WgXcQ' + await search() + assert.equal(input.value, '') + + panel.route('POST /api/queue/request', () => { + input.value = 'next song' + return { song: { title: 'Song' }, position: 1, started: false } + }) + input.value = 'https://youtu.be/dQw4w9WgXcQ' + await search() + assert.equal(input.value, 'next song') + }) + + await t.test('an answer that comes after a newer search is dropped', async () => { + const slow = deferred() + panel.route('GET /api/search?q=slow&admin=1', () => slow.promise) + panel.route('GET /api/search?q=quick&admin=1', { results: [track('q')] }) + + input.value = 'slow' + const first = search() + input.value = 'quick' + await search() + + slow.resolve({ results: [track('s1'), track('s2')] }) + await first + + assert.equal(results().length, 1) + }) + + await t.test('an answer that comes after the results were cleared does not bring them back', async () => { + const slow = deferred() + panel.route('GET /api/search?q=late&admin=1', () => slow.promise) + input.value = 'late' + const pending = search() + + clearSearchResults() + slow.resolve({ results: [track('l')] }) + await pending + + assert.equal(results().length, 0) + assert.equal(wrapperHidden(), true) + }) + await t.test('a track that starts playing at once is announced as playing', async () => { panel.route('POST /api/queue/request', { song: { title: 'Song' }, position: 1, started: true }) @@ -101,7 +166,7 @@ test('adding a track', async (t) => { panel.route('POST /api/queue/request', [409, { error: 'raw', code: 'DUPLICATE' }]) panel.calls.length = 0 - await searchActions['search-add']({ dataset: { videoId: V('z') } }) + await act(searchActions['search-add'], { dataset: { videoId: V('z') } }) assert.deepEqual(panel.calls, ['POST /api/queue/request']) assert.ok(panel.toasts().includes('This track is already in the queue')) diff --git a/tests/frontend/control/settings-copy.test.js b/tests/frontend/control/settings-copy.test.js new file mode 100644 index 0000000..fb33884 --- /dev/null +++ b/tests/frontend/control/settings-copy.test.js @@ -0,0 +1,76 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setupPanel } from './panel-env.js' + +const panel = setupPanel({ url: 'http://localhost:4747/' }) + +const { initI18n } = await import('../../../public/js/i18n.js') +await initI18n('en') +const { state } = await import('../../../public/js/control/state.js') +const { copyOverlayUrl, renderOverlayUrl, renderQrUrl } = await import('../../../public/js/control/settings.js') + +renderOverlayUrl() +const url = panel.$('#overlayUrl').href + +const useClipboard = (clipboard) => Object.defineProperty(globalThis, 'navigator', { value: { clipboard }, configurable: true }) + +test('copying the overlay link', async (t) => { + await t.test('uses the clipboard of the browser and says so', async () => { + const written = [] + useClipboard({ writeText: async (text) => written.push(text) }) + + await copyOverlayUrl() + + assert.deepEqual(written, [url]) + assert.ok(panel.toasts().includes('Copied')) + }) + + await t.test('selects and copies the text where the browser has no clipboard (a page opened by the LAN address)', async () => { + useClipboard(undefined) + const commands = [] + document.execCommand = (name) => commands.push(name) > 0 + + await copyOverlayUrl() + + assert.deepEqual(commands, ['copy']) + assert.equal(window.getSelection().toString(), panel.$('#overlayUrl').textContent) + }) + + await t.test('says so when it cannot copy, whichever way it tried', async () => { + useClipboard({ writeText: async () => Promise.reject(new Error('denied')) }) + await copyOverlayUrl() + assert.ok(panel.toasts().includes('Could not copy: select the link and copy it by hand')) + + // the same message is not shown twice at once, so what is checked here is that the refused command ended in the message too + useClipboard(undefined) + let tried = 0 + document.execCommand = () => (tried++, false) + const copied = () => panel.toasts().filter((text) => text === 'Copied').length + const before = copied() + + await copyOverlayUrl() + + assert.equal(tried, 1) + assert.equal(copied(), before, 'a copy that was refused does not say "Copied"') + }) +}) + +test('the QR code', async (t) => { + await t.test('a script that did not load is tried again the next time, and the failure is not left unhandled', async () => { + state.selectedIp = '192.168.0.5' + state.network = { ips: ['192.168.0.5'], port: 4747 } + panel.$('#controlPanelQr').classList.remove('hidden') + + let loads = 0 + document.head.appendChild = (script) => { + loads++ + queueMicrotask(() => script.onerror()) + return script + } + + await renderQrUrl() + await renderQrUrl() + + assert.equal(loads, 2) + }) +}) diff --git a/tests/frontend/control/settings.twitch.test.js b/tests/frontend/control/settings.twitch.test.js index 25342ed..dba721a 100644 --- a/tests/frontend/control/settings.twitch.test.js +++ b/tests/frontend/control/settings.twitch.test.js @@ -82,6 +82,9 @@ const { dom } = await import(new URL('js/control/dom.js', PUBLIC_DIR)) // the five chat commands, and the id of their inputs: chatCmd<Name>Enabled / Command / Permission const CHAT_COMMAND_FIELDS = ['now', 'queue', 'skip', 'pause', 'resume'].map((key) => ({ key, dom: `chatCmd${key[0].toUpperCase()}${key.slice(1)}` })) const settings = await import(new URL('js/control/twitch/index.js', PUBLIC_DIR)) +const { run } = await import(new URL('js/control/run.js', PUBLIC_DIR)) +// the dispatcher of the panel runs every action like this: a failure is logged and shown +const connect = () => run('connecting Twitch', () => settings.connectTwitch()) const configSettings = await import(new URL('js/control/settings.js', PUBLIC_DIR)) await loadTranslations('en') @@ -163,7 +166,7 @@ test('connectTwitch', async (t) => { await t.test('shows the code and the page to enter it on, and then waits for the server instead of asking it', async () => { handlers = { 'POST /api/integrations/twitch/connect': () => deviceCode() } - await drain(settings.connectTwitch()) + await drain(connect()) assert.equal(state.twitch.authorization.userCode, 'ABCD-1234') @@ -195,7 +198,7 @@ test('connectTwitch', async (t) => { await t.test('closes the popup and toasts when the device code response is incomplete', async () => { handlers = { 'POST /api/integrations/twitch/connect': () => ({ userCode: 'ABCD-1234' }) } - await drain(settings.connectTwitch()) + await drain(connect()) assert.equal(openedWindows[0].closed, true) assert.ok(document.getElementById('toastContainer').textContent.length > 0) @@ -209,7 +212,7 @@ test('connectTwitch', async (t) => { } } - await drain(settings.connectTwitch()) + await drain(connect()) assert.equal(openedWindows[0].closed, true, 'a failed connect must not leave a blank tab behind') assert.equal(dom.twitchConnectBtn.disabled, false) @@ -218,7 +221,7 @@ test('connectTwitch', async (t) => { await t.test('the connect button is usable again as soon as the code is shown (a new click replaces the code)', async () => { handlers = { 'POST /api/integrations/twitch/connect': () => deviceCode() } - await drain(settings.connectTwitch()) + await drain(connect()) assert.equal(dom.twitchConnectBtn.disabled, false) }) diff --git a/tests/frontend/control/toast.test.js b/tests/frontend/control/toast.test.js index 1ce0927..066d31a 100644 --- a/tests/frontend/control/toast.test.js +++ b/tests/frontend/control/toast.test.js @@ -42,7 +42,29 @@ test('toasts', async (t) => { assert.equal(shown().filter((text) => text === 'boom').length, 1) }) + await t.test('the same message of any kind is one message while it is on screen', () => { + toastSuccess('saved', { duration: 0 }) + toastSuccess('saved', { duration: 0 }) + + assert.equal(shown().filter((text) => text === 'saved').length, 1) + }) + + await t.test('a toast that leaves is removed after the time of its animation, and is not counted among those that stay', (t) => { + container.innerHTML = '' + t.mock.timers.enable({ apis: ['setTimeout'] }) + + for (const message of ['a', 'b', 'c', 'd']) toastSuccess(message, { duration: 0 }) + + assert.deepEqual(shown(), ['b', 'c', 'd']) + assert.equal(container.querySelectorAll('.toast-wrapper').length, 4, 'the first one is still leaving') + + t.mock.timers.tick(200) + assert.equal(container.querySelectorAll('.toast-wrapper').length, 3) + }) + await t.test('no more than three are shown, the oldest one makes room', () => { + container.innerHTML = '' + toastSuccess('one', { duration: 0 }) toastSuccess('two', { duration: 0 }) toastSuccess('three', { duration: 0 }) diff --git a/tests/frontend/control/twitch-reward-form.test.js b/tests/frontend/control/twitch-reward-form.test.js index 1fb2915..50b5a6d 100644 --- a/tests/frontend/control/twitch-reward-form.test.js +++ b/tests/frontend/control/twitch-reward-form.test.js @@ -260,6 +260,7 @@ test('saveReward()', async (t) => { assert.equal(state.twitch.selectedRewardId, 'new1') assert.equal(state.twitch.editingReward.id, 'new1') assert.ok(toasts().includes('toast.twitchRewardCreated')) + assert.equal(toasts().includes('toast.twitchSettingsSaved'), false, 'one message for the creation, not one more for the song-request setting') }) await t.test('an existing reward is updated by its id, shows what the server stored, and marks only what really changed as saved', async () => { diff --git a/tests/frontend/http.test.js b/tests/frontend/http.test.js new file mode 100644 index 0000000..4f89a83 --- /dev/null +++ b/tests/frontend/http.test.js @@ -0,0 +1,78 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { request, ApiError } from '../../public/js/http.js' + +let seen = null +let reply = () => ({ ok: true, status: 200, json: async () => ({ data: {} }) }) + +globalThis.fetch = async (url, options = {}) => { + seen = { url, options } + return reply() +} + +const answer = (status, body) => { + reply = () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + if (body === undefined) throw new SyntaxError('no body') + return body + } + }) +} + +test('request', async (t) => { + await t.test('gives the data of the reply', async () => { + answer(200, { data: { a: 1 } }) + + assert.deepEqual(await request('/api/x'), { a: 1 }) + }) + + await t.test('gives null for a reply without data', async () => { + answer(200, {}) + + assert.equal(await request('/api/x'), null) + }) + + await t.test('a refusal is an ApiError with what the server said about it', async () => { + answer(409, { error: 'raw text', code: 'DUPLICATE', params: { count: 2 } }) + + await assert.rejects(request('/api/x'), (error) => { + assert.ok(error instanceof ApiError) + assert.deepEqual([error.message, error.status, error.code, error.params], ['raw text', 409, 'DUPLICATE', { count: 2 }]) + return true + }) + }) + + await t.test('a refusal without a body still says what failed', async () => { + answer(502, undefined) + + await assert.rejects(request('/api/x'), (error) => error.message === 'Request failed: 502' && error.status === 502) + }) + + await t.test('a server that cannot be reached is a NETWORK_ERROR, with no status', async () => { + reply = () => { + throw new TypeError('fetch failed') + } + + await assert.rejects(request('/api/x'), (error) => error.code === 'NETWORK_ERROR' && error.status === undefined && error.cause instanceof TypeError) + }) + + await t.test('INVALID_CONFIG with data is a result when the endpoint asks for it, and a failure otherwise', async () => { + answer(400, { code: 'INVALID_CONFIG', error: 'bad', data: { rejected: ['a'] } }) + + assert.deepEqual(await request('/api/x', {}, { partial: true }), { rejected: ['a'] }) + await assert.rejects(request('/api/x'), (error) => error.code === 'INVALID_CONFIG') + }) + + await t.test('every request has a timeout, and a content type only when it has a body', async () => { + answer(200, { data: {} }) + + await request('/api/x') + assert.ok(seen.options.signal instanceof AbortSignal) + assert.equal(seen.options.headers['Content-Type'], undefined) + + await request('/api/x', { method: 'POST', body: '{}' }) + assert.equal(seen.options.headers['Content-Type'], 'application/json') + }) +}) diff --git a/tests/frontend/i18n.test.js b/tests/frontend/i18n.test.js index 322de53..8541bcb 100644 --- a/tests/frontend/i18n.test.js +++ b/tests/frontend/i18n.test.js @@ -14,7 +14,7 @@ globalThis.document = jsdom.window.document // what each /locales/<name>.json answers; a locale that is not listed fails to load const locales = { - en: { greeting: 'Hello', onlyEnglish: 'English only', nested: { value: 'Nested' }, price: 'Costs {{amount}}' }, + en: { greeting: 'Hello', onlyEnglish: 'English only', nested: { value: 'Nested' }, price: 'Costs {{amount}}', pair: '{{first}} / {{second}}' }, ru: { greeting: 'Привет' } } @@ -42,6 +42,15 @@ test('t()', async (t_) => { assert.equal(t('price', { amount: 5 }), 'Costs 5') assert.equal(t('price', { amount: "$& and $'" }), "Costs $& and $'") }) + + await t_.test('a value that looks like a placeholder is not put in again, whatever the order of the parameters', () => { + assert.equal(t('pair', { first: '{{second}}', second: 'B' }), '{{second}} / B') + assert.equal(t('pair', { second: '{{first}}', first: 'A' }), 'A / {{first}}') + }) + + await t_.test('a placeholder without a parameter stays as it is', () => { + assert.equal(t('price'), 'Costs {{amount}}') + }) }) test('loading a locale', async (t_) => { diff --git a/tests/frontend/locales.test.js b/tests/frontend/locales.test.js new file mode 100644 index 0000000..e1bf2e6 --- /dev/null +++ b/tests/frontend/locales.test.js @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const locale = (name) => JSON.parse(readFileSync(new URL(`../../public/locales/${name}.json`, import.meta.url), 'utf8')) + +const keysOf = (object, prefix = '') => + Object.entries(object).flatMap(([key, value]) => (typeof value === 'object' ? keysOf(value, `${prefix}${key}.`) : [`${prefix}${key}`])) + +test('both languages have the same keys', () => { + const en = keysOf(locale('en')) + const ru = keysOf(locale('ru')) + + assert.deepEqual(en.filter((key) => !ru.includes(key)), [], 'missing in ru') + assert.deepEqual(ru.filter((key) => !en.includes(key)), [], 'missing in en') +}) diff --git a/tests/frontend/overlay.test.js b/tests/frontend/overlay.test.js index 530bc7b..d59b123 100644 --- a/tests/frontend/overlay.test.js +++ b/tests/frontend/overlay.test.js @@ -335,6 +335,71 @@ test('a track that cannot be played', async (t) => { }) }) +test('a track that does not start', async (t) => { + const loads = (player) => player.calls.filter(([name]) => name === 'load').length + + await t.test('is retried like a temporary player error, and reported when the retries are used up', async (t) => { + const { players, sent, settle, elapse } = await boot(t, { current: track('a') }) + assert.equal(loads(players[0]), 1) + + await elapse(25_000) + await elapse(2000) + assert.equal(loads(players[0]), 2) + assert.deepEqual(sent.failures, []) + + await elapse(25_000) + await elapse(4000) + assert.equal(loads(players[0]), 3) + assert.deepEqual(sent.failures, []) + + await elapse(25_000) + await settle() + assert.deepEqual(sent.failures, [{ errorCode: 5, videoId: V('a') }]) + }) + + await t.test('is left alone once it plays', async (t) => { + const { players, sent, elapse } = await boot(t, { current: track('a') }) + + players[0].emit('onStateChange', 1) + await elapse(120_000) + + assert.equal(loads(players[0]), 1) + assert.deepEqual(sent.failures, []) + }) + + await t.test('is not waited for while the queue is paused', async (t) => { + const { players, sent, elapse } = await boot(t, { current: track('a'), isPaused: true }) + + await elapse(120_000) + + assert.equal(loads(players[0]), 0) + assert.deepEqual(sent.failures, []) + }) +}) + +test('the progress', async (t) => { + const progress = (env) => env.$('#progressBar').style.getPropertyValue('--progress') + + await t.test('of a live stream has no length to be counted against: nothing is written', async (t) => { + const { env, players, elapse } = await boot(t, { current: track('a', { duration: 0 }) }) + players[0].time = 5 + + await elapse(1000) + + assert.equal(progress(env), '') + assert.equal(env.$('#progressBar').classList.contains('hidden'), true) + }) + + await t.test('does not go past 100%', async (t) => { + const { env, players, elapse } = await boot(t, { current: track('a', { duration: 100 }) }) + players[0].time = 130 + + await elapse(1000) + + assert.equal(progress(env), '100%') + }) +}) + test('which overlay plays', async (t) => { await t.test('an overlay that does not hold the lock shows the badge but has no player, and gets one when the lock is granted', async (t) => { let grant = null @@ -343,6 +408,7 @@ test('which overlay plays', async (t) => { assert.equal(players.length, 0) assert.equal(env.$('#nowPlayingVideo').classList.contains('hidden'), true) + assert.equal(env.$('#progressWrapper').classList.contains('hidden'), true, 'no player, no time to show') assert.equal(env.$('#currentTitle').textContent, 'Track a') grant() @@ -350,6 +416,7 @@ test('which overlay plays', async (t) => { assert.equal(players.length, 1) assert.equal(env.$('#nowPlayingVideo').classList.contains('hidden'), false) + assert.equal(env.$('#progressWrapper').classList.contains('hidden'), false) }) }) diff --git a/tests/frontend/shared.test.js b/tests/frontend/shared.test.js index 551a38a..ef8224f 100644 --- a/tests/frontend/shared.test.js +++ b/tests/frontend/shared.test.js @@ -3,9 +3,9 @@ import assert from 'node:assert/strict' import { escapeHtml, formatDuration, + formatTrackDuration, youtubeThumbnail, formatViews, - getErrorMessage, translateErrorCode, setFieldState, setError, @@ -39,12 +39,28 @@ test('formatDuration', async (t) => { assert.equal(formatDuration(605), '10:05') }) - await t.test('zero', () => { - assert.equal(formatDuration(0, true), 'LIVE') + await t.test('zero, and what is no length at all, is 0:00', () => { + assert.equal(formatDuration(0), '0:00') + assert.equal(formatDuration(-5), '0:00') + assert.equal(formatDuration(undefined), '0:00') + assert.equal(formatDuration(NaN), '0:00') }) - await t.test('live', () => { - assert.equal(formatDuration(0), 'LIVE') + await t.test('shows hours from an hour on, with minutes padded', () => { + assert.equal(formatDuration(3600), '1:00:00') + assert.equal(formatDuration(3725), '1:02:05') + assert.equal(formatDuration(7325), '2:02:05') + }) +}) + +test('formatTrackDuration', async (t) => { + await t.test('a track with a length shows it', () => { + assert.equal(formatTrackDuration(65), '1:05') + }) + + await t.test('a track without one is a live stream, and the label is translated (the key itself while no language is loaded)', () => { + assert.equal(formatTrackDuration(0), 'common.live') + assert.equal(formatTrackDuration(null), 'common.live') }) }) @@ -94,31 +110,6 @@ test('formatViews', async (t) => { }) }) -test('getErrorMessage', async (t) => { - await t.test('known code without a translator returns the raw key', () => { - assert.equal(getErrorMessage(100), 'player.error.videoNotFound') - }) - - await t.test('known code with a translator calls it with the key and code param', () => { - const calls = [] - const fakeT = (key, params) => { - calls.push([key, params]) - return `translated:${key}` - } - assert.equal(getErrorMessage(101, fakeT), 'translated:player.error.embedNotAllowed') - assert.deepEqual(calls, [['player.error.embedNotAllowed', { code: 101 }]]) - }) - - await t.test('unknown code without a translator falls back to a generic string', () => { - assert.equal(getErrorMessage(9999), 'Error code 9999') - }) - - await t.test('unknown code with a translator uses the generic key', () => { - const fakeT = (key) => key - assert.equal(getErrorMessage(9999, fakeT), 'player.error.errorCode') - }) -}) - test('translateErrorCode', async (t) => { await t.test('no code returns the fallback', () => { assert.equal( diff --git a/tests/frontend/sse.test.js b/tests/frontend/sse.test.js index 22acab3..2e56db5 100644 --- a/tests/frontend/sse.test.js +++ b/tests/frontend/sse.test.js @@ -1,6 +1,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { connectEvents, serialized, watch } from '../../public/js/sse.js' +import { connectEvents } from '../../public/js/events.js' +import { serialized } from '../../public/js/serialized.js' +import { watch } from '../../public/js/watch.js' // What a browser gives the page: named events only (a ": comment" never reaches it), a readyState, and close() class FakeEventSource { @@ -272,11 +274,49 @@ test('serialized()', async (t) => { assert.equal(calls, 2) }) + + await t.test('a request that came during a failed run is not lost: the task runs again, and the promise follows the last run', async () => { + let calls = 0 + let release + const task = serialized(async () => { + calls++ + await new Promise((resolve) => (release = resolve)) + if (calls === 1) throw new Error('boom') + }) + + const first = task() + await Promise.resolve() + const second = task() + release() + await new Promise((resolve) => setImmediate(resolve)) + release() + + assert.equal(first, second, 'every caller gets the promise of the run that is going on') + await first + assert.equal(calls, 2) + }) + + await t.test('a failure of the last run rejects the promise', async () => { + const task = serialized(async () => { + throw new Error('always') + }) + + await assert.rejects(task(), /always/) + }) }) test('watch()', async (t) => { const flush = () => new Promise((resolve) => setImmediate(resolve)) + await t.test('a read that fails does not reject: nobody awaits what an event or a timer starts', async (t) => { + FakeEventSource.instances = [] + const watcher = watch({ a: { read: async () => Promise.reject(new Error('read failed')) } }, { EventSourceImpl: FakeEventSource }) + t.after(() => watcher.close()) + + await watcher.refresh('a') + await watcher.refreshAll() + }) + // a page with three topics: what each one reads is recorded, `shown` says what anyone looks at function page(t, { safetyMs = 1000, shown = {}, noEvents = false } = {}) { FakeEventSource.instances = []