diff --git a/.gitignore b/.gitignore index 5f27039..8943f13 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,13 @@ media/ tmp/ deploy/ node_modules/ + +# Local and test-only artifacts +.DS_Store +.idea/ +.vscode/ +*.log +coverage/ +.cache/ +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 62e1e84..856da77 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ minimum line, branch, and function coverage. Browser behavior is enforced by the separate Chromium suite and is not included in the server coverage percentage. GitHub Actions runs type checking, linting, formatting checks, server coverage, -and browser tests for pull requests and pushes to `master`. The `quality` job is -intended to be configured as a required status check for the protected branch. +and browser tests for pull requests targeting `master` and pushes to `master`. +`master` is the current primary CI/CD branch, and the `quality` job is intended +to be configured as a required status check for it. --- @@ -124,7 +125,13 @@ After one admin exists, manage roles from `/admin`. Continue with `SETUP.md` for ### Subsequent deploys -Run from a clean local checkout at the current `origin/master`. The script verifies the required GitHub Actions `quality` check, builds the hashed browser assets, rsyncs server code and assets, restarts the services, performs an HTTP smoke check, and records the deployed commit in `/opt/vidium/.deployed-revision`. +Run from a clean local checkout at the current `origin/master`. The deployment +scripts intentionally deploy only the current `master` revision: they verify +that `HEAD` matches `origin/master` and that the required GitHub Actions +`quality` check succeeded for that commit. They then build the hashed browser +assets, rsync server code and assets, restart the services, perform an HTTP +smoke check, and record the deployed commit in +`/opt/vidium/.deployed-revision`. ```bash scripts/deploy.sh root@ diff --git a/docs/deploy.md b/docs/deploy.md index b1f0169..264fe2e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -4,6 +4,20 @@ vidium is deployed with rsync from a clean local checkout. The local machine bui The supported bootstrap platform is Ubuntu 24.04 x86_64. Both `setup.sh` and `dev-env-setup.sh` reject other architectures before downloads or machine changes. +## CI/CD Branch Policy + +`master` is the current primary CI/CD branch. GitHub Actions runs the `quality` +workflow for pull requests targeting `master` and for pushes to `master`. + +Production deployment is also tied to `master`: `scripts/deploy.sh` and +`scripts/deploy-static.sh` require a clean checkout whose `HEAD` matches the +current `origin/master`, then require a successful GitHub Actions `quality` +check for that exact commit before changing the VPS. A different local branch +or revision must not be deployed through the supported scripts. + +If the primary branch changes in the future, update the GitHub Actions workflow, +deployment scripts, and this documentation together. + ## Runtime And Maintenance Files The production app directory is `/opt/vidium`. Application services run as the dedicated unprivileged `vidium` account; code and the pinned runtime remain root-owned. diff --git a/scripts/register-local-video.ts b/scripts/register-local-video.ts index ecd68bf..f092984 100644 --- a/scripts/register-local-video.ts +++ b/scripts/register-local-video.ts @@ -11,7 +11,7 @@ import { existsSync } from 'node:fs'; import { config } from '../src/config.ts'; import { db } from '../src/lib/db.ts'; -import { generateVideoUid } from '../src/lib/video.ts'; +import { generateVideoUid } from '../src/lib/video-mutations.ts'; const MEDIA_ID_RE = /^[a-zA-Z0-9_-]+$/; const MANUAL_CHANNEL_ID = 1; diff --git a/scripts/tests/frontend-browser.test.mjs b/scripts/tests/frontend-browser.test.mjs index 31130b2..8e9180f 100644 --- a/scripts/tests/frontend-browser.test.mjs +++ b/scripts/tests/frontend-browser.test.mjs @@ -1567,6 +1567,52 @@ browserTest( }, ); +browserTest( + 'media queue polling reports one warning per failure streak and retries', + async (page) => { + await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') }); + const warnings = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + await page.evaluate((uid) => { + localStorage.setItem( + 'vidium:media-queue:v1', + JSON.stringify([{ uid, type: 'video', title: 'Queued', status: 'queued', addedAt: 1 }]), + ); + }, UID_ONE); + let calls = 0; + await page.route('**/api/status?*', async (route) => { + calls += 1; + await page.evaluate((value) => { + window.__statusPollCalls = value; + }, calls); + await route.fulfill({ status: 503, json: { error: 'temporary' } }); + }); + await mountRealPart(page, { + partName: 'media-queue', + id: 'media-queue', + state: mediaQueueState(), + }); + await page.locator('[data-action="open"]').click(); + await page.clock.runFor(0); + await page.waitForFunction( + () => window.__VIDIUM_TEST__.instances['media-queue'].private.pollErrorReported, + ); + await page.waitForFunction( + () => window.__VIDIUM_TEST__.instances['media-queue'].private.pollTimer !== null, + ); + await page.clock.runFor(5000); + await page.waitForFunction(() => window.__statusPollCalls === 2); + + assert.equal(calls, 2); + assert.equal( + warnings.filter((value) => value.includes('Media queue polling failed')).length, + 1, + ); + }, +); + async function mockPlayerMedia(page, id, values = {}) { await page.evaluate( ({ instanceId, initial }) => { @@ -1813,6 +1859,89 @@ browserTest('player records the first play event only once', async (page) => { assert.equal(playRequests, 1); }); +browserTest( + 'player handles browser play rejection and reports failed play recording', + async (page) => { + await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') }); + const warnings = []; + const pageErrors = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('**/api/play', (route) => + route.fulfill({ status: 503, json: { error: 'temporary' } }), + ); + await mountRealPart(page, { + partName: 'player-page', + id: 'player-page', + state: playerState(), + }); + await mockPlayerMedia(page, 'player-page', { paused: true }); + await page.evaluate(() => { + const media = window.__VIDIUM_TEST__.instances['player-page'].refs.media; + media.play = () => + Promise.reject(Object.assign(new Error('autoplay blocked'), { name: 'NotAllowedError' })); + }); + + await page.locator('[data-action="toggle-play"]').click(); + await page.evaluate(() => { + window.__VIDIUM_TEST__.instances['player-page'].refs.media.dispatchEvent(new Event('play')); + }); + await page.waitForFunction( + () => window.__VIDIUM_TEST__.instances['player-page'].private.playRecordErrorReported, + ); + + assert.deepEqual(pageErrors, []); + assert.equal( + warnings.filter((value) => value.includes('play event recording failed')).length, + 1, + ); + assert.equal(warnings.filter((value) => value.includes('media play failed')).length, 0); + }, +); + +browserTest('player reports resume storage failure without breaking controls', async (page) => { + await page.clock.install({ time: new Date('2026-08-16T12:00:00Z') }); + const warnings = []; + const pageErrors = []; + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await mountRealPart(page, { + partName: 'player-page', + id: 'player-page', + state: playerState(), + }); + await mockPlayerMedia(page, 'player-page', { currentTime: 100, paused: true }); + await page.evaluate(() => { + Object.defineProperty(Storage.prototype, 'setItem', { + configurable: true, + value() { + throw new DOMException('denied'); + }, + }); + }); + + await page.locator('[data-action="seek"][data-seek="15"]').click(); + await page.waitForFunction( + () => window.__VIDIUM_TEST__.instances['player-page'].private.resumeStorageErrorReported, + ); + + assert.deepEqual(pageErrors, []); + assert.equal( + warnings.filter((value) => value.includes('resume storage is unavailable')).length, + 1, + ); + assert.equal( + await page.evaluate( + () => window.__VIDIUM_TEST__.instances['player-page'].refs.media.currentTime, + ), + 115, + ); +}); + browserTest( 'player share and clipboard handle success, rejection, and late completion after destroy', async (page) => { diff --git a/src/handlers/api-admin.ts b/src/handlers/api-admin.ts new file mode 100644 index 0000000..60d5f36 --- /dev/null +++ b/src/handlers/api-admin.ts @@ -0,0 +1,460 @@ +/** API handlers for administrative channel, video, tag, job, and user actions. */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { unlink } from 'node:fs/promises'; +import { config } from '../config.ts'; +import { checkCsrf, json, readBody, requireAdminApi } from '../lib/http.ts'; +import { setUserRole, type UserRole } from '../lib/auth/auth.ts'; +import { + deleteDownloadJobsByYoutubeId, + deleteJobById, + deleteJobsByYoutubeId, + enqueue, + getJobAdminById, +} from '../lib/queue.ts'; +import { isValidVideoId, CHANNEL_URL_RE, VIDEO_URL_RE } from '../lib/validation.ts'; +import { + deleteVideoByYoutubeId, + insertVideos, + setAudioStatus, + setMediaStatusesNone, + setVideoStatus, + videoExists, +} from '../lib/video-mutations.ts'; +import { getVideoByYoutubeId } from '../lib/video-queries.ts'; +import { + MANUAL_CHANNEL_ID, + addChannel, + deleteTag, + moveChannel as moveChannelOrder, + moveTag as moveTagOrder, + normalizeChannelTags, + setChannelAutoDownload, + setChannelDisplayName, + setChannelGuestVisible, + setChannelRssEnabled, + setChannelTags, +} from '../lib/channel.ts'; +import { fetchMeta } from '../lib/ytdlp.ts'; + +async function unlinkIfExists(path: string): Promise { + try { + await unlink(path); + return true; + } catch (err) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') return false; + throw err; + } +} + +export async function handleAddChannel(req: IncomingMessage, res: ServerResponse): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { url: string; tags?: string; displayName?: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!data.url || !CHANNEL_URL_RE.test(data.url)) { + return json(res, 400, { error: 'invalid channel URL — use https://www.youtube.com/@name' }); + } + + const name = decodeURIComponent(data.url.match(/youtube\.com\/@([^/?#]+)/)?.[1] ?? ''); + const canonicalUrl = `https://www.youtube.com/@${name}`; + const userTags = normalizeChannelTags(data.tags ?? ''); + const displayName = (data.displayName ?? '').trim(); + const tags = userTags.join(','); + + const { id, created } = addChannel(name, canonicalUrl, tags, displayName); + if (!created) return json(res, 200, { ok: true, status: 'exists' }); + + enqueue('crawl_channel', { channelId: id, url: canonicalUrl }); + if (data.url !== canonicalUrl) enqueue('crawl_channel', { channelId: id, url: data.url }); + + json(res, 200, { ok: true, status: 'added', channelId: id }); +} + +export async function handleAddVideo(req: IncomingMessage, res: ServerResponse): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { url: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + const match = (data.url ?? '').match(VIDEO_URL_RE); + if (!match) return json(res, 400, { error: 'invalid YouTube video URL' }); + + const youtubeId = match[1]; + if (videoExists(youtubeId)) return json(res, 200, { ok: true, status: 'exists' }); + + let meta: { title: string; date: string; duration: number }; + try { + meta = await fetchMeta(youtubeId); + } catch { + return json(res, 502, { error: 'failed to fetch video metadata' }); + } + + insertVideos( + [{ youtubeId, title: meta.title, date: meta.date, duration: meta.duration }], + MANUAL_CHANNEL_ID, + 'manual', + ); + enqueue('download_thumbnail', { youtubeId }); + + json(res, 200, { ok: true, status: 'added', youtubeId }); +} + +export async function handleSetChannelDisplayName( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; displayName: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= MANUAL_CHANNEL_ID || + typeof data.displayName !== 'string' + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const saved = setChannelDisplayName(data.channelId, data.displayName.trim()); + json(res, 200, { ok: true, saved }); +} + +export async function handleSetChannelTags( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; tags: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= MANUAL_CHANNEL_ID || + typeof data.tags !== 'string' + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const tags = normalizeChannelTags(data.tags).join(','); + const saved = setChannelTags(data.channelId, tags); + json(res, 200, { ok: true, saved, tags }); +} + +export async function handleSetChannelAutoDownload( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; type: 'video' | 'audio'; enabled: boolean }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= MANUAL_CHANNEL_ID || + !['video', 'audio'].includes(data.type) || + typeof data.enabled !== 'boolean' + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const saved = setChannelAutoDownload(data.channelId, data.type, data.enabled); + json(res, 200, { ok: true, saved, type: data.type, enabled: data.enabled }); +} + +export async function handleSetChannelGuestVisible( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; enabled: boolean }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= MANUAL_CHANNEL_ID || + typeof data.enabled !== 'boolean' + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const saved = setChannelGuestVisible(data.channelId, data.enabled); + json(res, 200, { ok: true, saved, enabled: data.enabled }); +} + +export async function handleSetChannelRssEnabled( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; enabled: boolean }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= MANUAL_CHANNEL_ID || + typeof data.enabled !== 'boolean' + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const saved = setChannelRssEnabled(data.channelId, data.enabled); + json(res, 200, { ok: true, saved, enabled: data.enabled }); +} + +export async function handleReorderChannel( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { channelId: number; direction: 'up' | 'down' }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if ( + !Number.isInteger(data.channelId) || + data.channelId <= 1 || + !['up', 'down'].includes(data.direction) + ) { + return json(res, 400, { error: 'invalid request' }); + } + + const moved = moveChannelOrder(data.channelId, data.direction); + json(res, 200, { ok: true, moved }); +} + +export async function handleReorderTag(req: IncomingMessage, res: ServerResponse): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { tag: string; direction: 'up' | 'down' }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + const tag = typeof data.tag === 'string' ? normalizeChannelTags(data.tag)[0] : ''; + if (!tag || tag !== data.tag || !['up', 'down'].includes(data.direction)) { + return json(res, 400, { error: 'invalid request' }); + } + + const moved = moveTagOrder(data.tag, data.direction); + json(res, 200, { ok: true, moved }); +} + +export async function handleDeleteTag(req: IncomingMessage, res: ServerResponse): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { tag: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + const tag = typeof data.tag === 'string' ? normalizeChannelTags(data.tag)[0] : ''; + if (!tag || tag !== data.tag) return json(res, 400, { error: 'invalid request' }); + + const deleted = deleteTag(data.tag); + json(res, 200, { ok: true, deleted, tag: data.tag }); +} + +export async function handleAdminDeleteVideoFiles( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { youtubeId: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); + + const videoDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/videos/${data.youtubeId}.mp4`); + const audioDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/audio/${data.youtubeId}.m4a`); + setMediaStatusesNone(data.youtubeId, { video: videoDeleted, audio: audioDeleted }); + + json(res, 200, { ok: true, videoDeleted, audioDeleted }); +} + +export async function handleAdminDeleteVideo( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { youtubeId: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); + + const videoDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/videos/${data.youtubeId}.mp4`); + const audioDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/audio/${data.youtubeId}.m4a`); + const videoRemoved = deleteVideoByYoutubeId(data.youtubeId); + const jobsRemoved = deleteJobsByYoutubeId(data.youtubeId); + + json(res, 200, { ok: true, videoDeleted, audioDeleted, videoRemoved, jobsRemoved }); +} + +type ResetStatusResult = { youtubeId: string; statusType: 'video' | 'audio' }; +const RESETTABLE_MEDIA_STATUSES = new Set(['queued', 'downloading', 'expired']); + +function resetDownloadJobStatus(jobId: number): ResetStatusResult | null { + const job = getJobAdminById(jobId); + if (!job || job.status === 'done' || !job.youtubeId) return null; + + const video = getVideoByYoutubeId(job.youtubeId); + if (!video) return null; + + if (job.type === 'download_video') { + if (!RESETTABLE_MEDIA_STATUSES.has(video.videoStatus)) return null; + setVideoStatus(job.youtubeId, 'none'); + return { youtubeId: job.youtubeId, statusType: 'video' }; + } + + if (job.type === 'download_audio') { + if (!RESETTABLE_MEDIA_STATUSES.has(video.audioStatus)) return null; + setAudioStatus(job.youtubeId, 'none'); + return { youtubeId: job.youtubeId, statusType: 'audio' }; + } + + return null; +} + +export async function handleAdminDeleteJob( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { jobId: number }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!Number.isInteger(data.jobId) || data.jobId <= 0) { + return json(res, 400, { error: 'invalid request' }); + } + + const resetStatus = resetDownloadJobStatus(data.jobId); + const deleted = deleteJobById(data.jobId); + json(res, 200, { ok: true, deleted, resetStatus }); +} + +export async function handleAdminResetVideoStatus( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!requireAdminApi(req, res)) return; + if (!checkCsrf(req, res)) return; + + let data: { youtubeId: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); + + const video = getVideoByYoutubeId(data.youtubeId); + if (!video) return json(res, 404, { error: 'not found' }); + + const resetVideo = RESETTABLE_MEDIA_STATUSES.has(video.videoStatus); + const resetAudio = RESETTABLE_MEDIA_STATUSES.has(video.audioStatus); + setMediaStatusesNone(data.youtubeId, { video: resetVideo, audio: resetAudio }); + const jobsRemoved = deleteDownloadJobsByYoutubeId(data.youtubeId); + + json(res, 200, { + ok: true, + resetStatus: { youtubeId: data.youtubeId, resetVideo, resetAudio }, + jobsRemoved, + }); +} + +export async function handleAdminSetUserRole( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const session = requireAdminApi(req, res); + if (!session) return; + if (!checkCsrf(req, res)) return; + + let data: { userId: number; role: UserRole }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!Number.isInteger(data.userId) || !['user', 'admin'].includes(data.role)) { + return json(res, 400, { error: 'invalid request' }); + } + + if (data.userId === session.userId && data.role !== 'admin') { + return json(res, 403, { error: 'cannot change your own admin role' }); + } + + const saved = setUserRole(data.userId, data.role); + json(res, 200, { ok: true, saved }); +} diff --git a/src/handlers/api-feed.ts b/src/handlers/api-feed.ts new file mode 100644 index 0000000..a451a5f --- /dev/null +++ b/src/handlers/api-feed.ts @@ -0,0 +1,115 @@ +/** API handlers for feed data, status polling, and session feed preferences. */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { updateSessionData } from '../lib/auth/sessions.ts'; +import { + checkCsrf, + getOptionalSession, + getQuery, + json, + readBody, + requireSessionApi, +} from '../lib/http.ts'; +import { normalizeGuestFeedTag } from '../lib/feed-tags.ts'; +import { getGuestVisibleVideo } from '../lib/guest-access.ts'; +import { isValidUid } from '../lib/validation.ts'; +import { + DEFAULT_VIDEO_PAGE_SIZE, + getGuestVideoPage, + getNewReadyVideosSince, + getNewVideosSince, + getNewVideosSinceByChannel, + getNewVideosSinceByTag, + getVideoByUid, + getVideoPage, + toPublicVideoRow, +} from '../lib/video-queries.ts'; + +export async function handleSidebarMode(req: IncomingMessage, res: ServerResponse): Promise { + const session = requireSessionApi(req, res); + if (!session) return; + if (!checkCsrf(req, res)) return; + + let data: { mode: string }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!['channels', 'tags'].includes(data.mode)) { + return json(res, 400, { error: 'invalid request' }); + } + + updateSessionData(session.sid, { + ...session.data, + sidebarMode: data.mode as 'channels' | 'tags', + }); + json(res, 200, { ok: true, mode: data.mode }); +} + +export function handleStatus(req: IncomingMessage, res: ServerResponse): void { + const session = getOptionalSession(req); + + const ids = (getQuery(req).ids ?? '').split(',').filter(Boolean); + if (!ids.length) return json(res, 200, {}); + + const result: Record = {}; + for (const uid of ids) { + if (!isValidUid(uid)) continue; + const v = session ? getVideoByUid(uid) : getGuestVisibleVideo(uid); + if (v) result[uid] = { video: v.videoStatus, audio: v.audioStatus }; + } + + json(res, 200, result); +} + +export function handleSince(req: IncomingMessage, res: ServerResponse): void { + if (!requireSessionApi(req, res)) return; + + const q = getQuery(req); + const ts = parseInt(q.t ?? '0', 10); + const sinceIso = new Date(Number.isFinite(ts) ? ts : 0).toISOString(); + const tag = (q.tag ?? '').trim(); + const channelId = Number.parseInt(q.channelId ?? '', 10); + + const rows = + Number.isInteger(channelId) && channelId > 0 + ? getNewVideosSinceByChannel(sinceIso, channelId) + : tag === 'ready' + ? getNewReadyVideosSince(sinceIso) + : tag && tag !== 'all' + ? getNewVideosSinceByTag(sinceIso, tag) + : getNewVideosSince(sinceIso); + + json( + res, + 200, + rows.map((r) => toPublicVideoRow(r)), + ); +} + +export function handleFeedCards(req: IncomingMessage, res: ServerResponse): void { + const session = getOptionalSession(req); + + const q = getQuery(req); + const page = Number.parseInt(q.page ?? '1', 10); + const channelId = Number.parseInt(q.channelId ?? '', 10); + const rawTag = (q.tag ?? 'all').trim() || 'all'; + const query = { + page: Number.isInteger(page) && page > 0 ? page : 1, + pageSize: DEFAULT_VIDEO_PAGE_SIZE, + tag: session ? rawTag : normalizeGuestFeedTag(rawTag), + channelId: Number.isInteger(channelId) && channelId > 0 ? channelId : 0, + }; + const result = session ? getVideoPage(query) : getGuestVideoPage(query); + + json(res, 200, { + ok: true, + cards: result.items.map(toPublicVideoRow), + page: result.page, + pageSize: result.pageSize, + pageCount: result.pageCount, + total: result.total, + }); +} diff --git a/src/handlers/api-media.ts b/src/handlers/api-media.ts new file mode 100644 index 0000000..12909de --- /dev/null +++ b/src/handlers/api-media.ts @@ -0,0 +1,75 @@ +/** API handlers for media queueing and play statistics. */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { checkCsrf, getOptionalSession, json, readBody } from '../lib/http.ts'; +import { getTrustedClientIp } from '../lib/client-ip.ts'; +import { enqueue } from '../lib/queue.ts'; +import { isValidUid } from '../lib/validation.ts'; +import { getVideoByUid } from '../lib/video-queries.ts'; +import { setAudioStatus, setVideoStatus } from '../lib/video-mutations.ts'; +import { canGuestAccessVideo, getGuestVisibleVideo } from '../lib/guest-access.ts'; +import { recordPlayEvent } from '../lib/play-stats.ts'; + +export async function handleDownload(req: IncomingMessage, res: ServerResponse): Promise { + const session = getOptionalSession(req); + if (!checkCsrf(req, res)) return; + + let data: { uid: string; type: 'video' | 'audio' }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!isValidUid(data.uid) || !['video', 'audio'].includes(data.type)) { + return json(res, 400, { error: 'invalid request' }); + } + + const video = session ? getVideoByUid(data.uid) : getGuestVisibleVideo(data.uid); + if (!video) return json(res, 404, { error: 'not found' }); + + const currentStatus = data.type === 'video' ? video.videoStatus : video.audioStatus; + if (!['none', 'expired'].includes(currentStatus)) { + return json(res, 200, { ok: true, status: currentStatus }); + } + + const jobType = data.type === 'video' ? 'download_video' : 'download_audio'; + + if (data.type === 'video') setVideoStatus(video.youtubeId, 'queued'); + else setAudioStatus(video.youtubeId, 'queued'); + enqueue(jobType, { youtubeId: video.youtubeId }); + + json(res, 200, { ok: true, status: 'queued' }); +} + +export async function handlePlay(req: IncomingMessage, res: ServerResponse): Promise { + if (!checkCsrf(req, res)) return; + + let data: { uid: string; kind: 'video' | 'audio' }; + try { + data = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: 'invalid json' }); + } + + if (!isValidUid(data.uid) || !['video', 'audio'].includes(data.kind)) { + return json(res, 400, { error: 'invalid request' }); + } + + const session = getOptionalSession(req); + const video = session ? getVideoByUid(data.uid) : getGuestVisibleVideo(data.uid); + if (!video) return json(res, 404, { error: 'not found' }); + if (!session && !canGuestAccessVideo(data.uid, data.kind)) { + return json(res, 404, { error: 'not found' }); + } + + const actor = session ? `user:${session.userId}` : `guest:${getTrustedClientIp(req)}`; + const result = recordPlayEvent(data.uid, data.kind, actor); + if (result === 'not_found') return json(res, 404, { error: 'not found' }); + if (result === 'rate_limited') { + res.setHeader('Retry-After', '3600'); + return json(res, 429, { error: 'rate limited' }); + } + + json(res, 200, { ok: true, recorded: result === 'recorded' }); +} diff --git a/src/handlers/api.ts b/src/handlers/api.ts index 190889a..66200db 100644 --- a/src/handlers/api.ts +++ b/src/handlers/api.ts @@ -1,654 +1,21 @@ -/** - * handlers/api.ts — JSON API endpoints. - * - * POST /api/download — enqueue video or audio download (body uses public uid) - * GET /api/status — poll job status for given public uids - * GET /api/since — new videos since a timestamp - * GET /api/feed/cards — paginated feed card data - * POST /api/channel — add channel + enqueue crawl - * POST /api/video — add single video by URL - * POST /api/channel/display-name — rename channel in sidebar - * POST /api/channel/rss-enabled — enable or disable channel RSS polling - * POST /api/tag/reorder — change tag order in sidebar - * POST /api/play — record first play on player page - */ - -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { unlink } from 'node:fs/promises'; -import { config } from '../config.ts'; -import { - getOptionalSession, - requireSessionApi, - requireAdminApi, - checkCsrf, - readBody, - getQuery, - json, -} from '../lib/http.ts'; -import { setUserRole, type UserRole } from '../lib/auth/auth.ts'; -import { updateSessionData } from '../lib/auth/sessions.ts'; -import { - deleteDownloadJobsByYoutubeId, - deleteJobById, - deleteJobsByYoutubeId, - enqueue, - getJobAdminById, -} from '../lib/queue.ts'; -import { isValidUid, isValidVideoId, CHANNEL_URL_RE, VIDEO_URL_RE } from '../lib/validation.ts'; -import { - deleteVideoByYoutubeId, - getVideoByUid, - getVideoByYoutubeId, - toPublicVideoRow, - videoExists, - setVideoStatus, - setAudioStatus, - setMediaStatusesNone, - insertVideos, - DEFAULT_VIDEO_PAGE_SIZE, - getGuestVideoPage, - getVideoPage, - getNewVideosSince, - getNewVideosSinceByChannel, - getNewVideosSinceByTag, - getNewReadyVideosSince, -} from '../lib/video.ts'; -import { - addChannel, - deleteTag, - MANUAL_CHANNEL_ID, - setChannelDisplayName, - setChannelAutoDownload, - setChannelGuestVisible, - setChannelRssEnabled, - setChannelTags, - moveTag as moveTagOrder, - moveChannel as moveChannelOrder, - normalizeChannelTags, -} from '../lib/channel.ts'; -import { normalizeGuestFeedTag } from '../lib/feed-tags.ts'; -import { canGuestAccessVideo, getGuestVisibleVideo } from '../lib/guest-access.ts'; -import { recordPlayEvent } from '../lib/play-stats.ts'; -import { getTrustedClientIp } from '../lib/client-ip.ts'; -import { fetchMeta } from '../lib/ytdlp.ts'; - -async function unlinkIfExists(path: string): Promise { - try { - await unlink(path); - return true; - } catch (err) { - const e = err as { code?: string }; - if (e.code === 'ENOENT') return false; - throw err; - } -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -export async function handleDownload(req: IncomingMessage, res: ServerResponse): Promise { - const session = getOptionalSession(req); - if (!checkCsrf(req, res)) return; - - let data: { uid: string; type: 'video' | 'audio' }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!isValidUid(data.uid) || !['video', 'audio'].includes(data.type)) { - return json(res, 400, { error: 'invalid request' }); - } - - const video = session ? getVideoByUid(data.uid) : getGuestVisibleVideo(data.uid); - if (!video) return json(res, 404, { error: 'not found' }); - - const currentStatus = data.type === 'video' ? video.videoStatus : video.audioStatus; - if (!['none', 'expired'].includes(currentStatus)) { - return json(res, 200, { ok: true, status: currentStatus }); - } - - const jobType = data.type === 'video' ? 'download_video' : 'download_audio'; - - if (data.type === 'video') setVideoStatus(video.youtubeId, 'queued'); - else setAudioStatus(video.youtubeId, 'queued'); - enqueue(jobType, { youtubeId: video.youtubeId }); - - json(res, 200, { ok: true, status: 'queued' }); -} - -export async function handlePlay(req: IncomingMessage, res: ServerResponse): Promise { - if (!checkCsrf(req, res)) return; - - let data: { uid: string; kind: 'video' | 'audio' }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!isValidUid(data.uid) || !['video', 'audio'].includes(data.kind)) { - return json(res, 400, { error: 'invalid request' }); - } - - const session = getOptionalSession(req); - const video = session ? getVideoByUid(data.uid) : getGuestVisibleVideo(data.uid); - if (!video) return json(res, 404, { error: 'not found' }); - if (!session && !canGuestAccessVideo(data.uid, data.kind)) { - return json(res, 404, { error: 'not found' }); - } - - const actor = session ? `user:${session.userId}` : `guest:${getTrustedClientIp(req)}`; - const result = recordPlayEvent(data.uid, data.kind, actor); - if (result === 'not_found') return json(res, 404, { error: 'not found' }); - if (result === 'rate_limited') { - res.setHeader('Retry-After', '3600'); - return json(res, 429, { error: 'rate limited' }); - } - - json(res, 200, { ok: true, recorded: result === 'recorded' }); -} - -export async function handleSidebarMode(req: IncomingMessage, res: ServerResponse): Promise { - const session = requireSessionApi(req, res); - if (!session) return; - if (!checkCsrf(req, res)) return; - - let data: { mode: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!['channels', 'tags'].includes(data.mode)) { - return json(res, 400, { error: 'invalid request' }); - } - - updateSessionData(session.sid, { - ...session.data, - sidebarMode: data.mode as 'channels' | 'tags', - }); - json(res, 200, { ok: true, mode: data.mode }); -} - -export function handleStatus(req: IncomingMessage, res: ServerResponse): void { - const session = getOptionalSession(req); - - const ids = (getQuery(req).ids ?? '').split(',').filter(Boolean); - if (!ids.length) return json(res, 200, {}); - - const result: Record = {}; - for (const uid of ids) { - if (!isValidUid(uid)) continue; - const v = session ? getVideoByUid(uid) : getGuestVisibleVideo(uid); - if (v) result[uid] = { video: v.videoStatus, audio: v.audioStatus }; - } - - json(res, 200, result); -} - -export function handleSince(req: IncomingMessage, res: ServerResponse): void { - if (!requireSessionApi(req, res)) return; - - const q = getQuery(req); - const ts = parseInt(q.t ?? '0', 10); - const sinceIso = new Date(Number.isFinite(ts) ? ts : 0).toISOString(); - const tag = (q.tag ?? '').trim(); - const channelId = Number.parseInt(q.channelId ?? '', 10); - - const rows = - Number.isInteger(channelId) && channelId > 0 - ? getNewVideosSinceByChannel(sinceIso, channelId) - : tag === 'ready' - ? getNewReadyVideosSince(sinceIso) - : tag && tag !== 'all' - ? getNewVideosSinceByTag(sinceIso, tag) - : getNewVideosSince(sinceIso); - - json( - res, - 200, - rows.map((r) => toPublicVideoRow(r)), - ); -} - -export function handleFeedCards(req: IncomingMessage, res: ServerResponse): void { - const session = getOptionalSession(req); - - const q = getQuery(req); - const page = Number.parseInt(q.page ?? '1', 10); - const channelId = Number.parseInt(q.channelId ?? '', 10); - const rawTag = (q.tag ?? 'all').trim() || 'all'; - const query = { - page: Number.isInteger(page) && page > 0 ? page : 1, - pageSize: DEFAULT_VIDEO_PAGE_SIZE, - tag: session ? rawTag : normalizeGuestFeedTag(rawTag), - channelId: Number.isInteger(channelId) && channelId > 0 ? channelId : 0, - }; - const result = session ? getVideoPage(query) : getGuestVideoPage(query); - - json(res, 200, { - ok: true, - cards: result.items.map(toPublicVideoRow), - page: result.page, - pageSize: result.pageSize, - pageCount: result.pageCount, - total: result.total, - }); -} - -export async function handleAddChannel(req: IncomingMessage, res: ServerResponse): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { url: string; tags?: string; displayName?: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!data.url || !CHANNEL_URL_RE.test(data.url)) { - return json(res, 400, { error: 'invalid channel URL — use https://www.youtube.com/@name' }); - } - - const name = decodeURIComponent(data.url.match(/youtube\.com\/@([^/?#]+)/)?.[1] ?? ''); - const canonicalUrl = `https://www.youtube.com/@${name}`; - const userTags = normalizeChannelTags(data.tags ?? ''); - const displayName = (data.displayName ?? '').trim(); - const tags = userTags.join(','); - - const { id, created } = addChannel(name, canonicalUrl, tags, displayName); - if (!created) return json(res, 200, { ok: true, status: 'exists' }); - - enqueue('crawl_channel', { channelId: id, url: canonicalUrl }); - if (data.url !== canonicalUrl) { - enqueue('crawl_channel', { channelId: id, url: data.url }); - } - - json(res, 200, { ok: true, status: 'added', channelId: id }); -} - -export async function handleAddVideo(req: IncomingMessage, res: ServerResponse): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { url: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - const match = (data.url ?? '').match(VIDEO_URL_RE); - if (!match) return json(res, 400, { error: 'invalid YouTube video URL' }); - - const youtubeId = match[1]; - if (videoExists(youtubeId)) return json(res, 200, { ok: true, status: 'exists' }); - - let meta: { title: string; date: string; duration: number }; - try { - meta = await fetchMeta(youtubeId); - } catch { - return json(res, 502, { error: 'failed to fetch video metadata' }); - } - - insertVideos( - [{ youtubeId, title: meta.title, date: meta.date, duration: meta.duration }], - MANUAL_CHANNEL_ID, - 'manual', - ); - enqueue('download_thumbnail', { youtubeId }); - - json(res, 200, { ok: true, status: 'added', youtubeId }); -} - -export async function handleSetChannelDisplayName( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; displayName: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= MANUAL_CHANNEL_ID || - typeof data.displayName !== 'string' - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const saved = setChannelDisplayName(data.channelId, data.displayName.trim()); - json(res, 200, { ok: true, saved }); -} - -export async function handleSetChannelTags( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; tags: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= MANUAL_CHANNEL_ID || - typeof data.tags !== 'string' - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const tags = normalizeChannelTags(data.tags).join(','); - const saved = setChannelTags(data.channelId, tags); - json(res, 200, { ok: true, saved, tags }); -} - -export async function handleSetChannelAutoDownload( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; type: 'video' | 'audio'; enabled: boolean }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= MANUAL_CHANNEL_ID || - !['video', 'audio'].includes(data.type) || - typeof data.enabled !== 'boolean' - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const saved = setChannelAutoDownload(data.channelId, data.type, data.enabled); - json(res, 200, { ok: true, saved, type: data.type, enabled: data.enabled }); -} - -export async function handleSetChannelGuestVisible( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; enabled: boolean }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= MANUAL_CHANNEL_ID || - typeof data.enabled !== 'boolean' - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const saved = setChannelGuestVisible(data.channelId, data.enabled); - json(res, 200, { ok: true, saved, enabled: data.enabled }); -} - -export async function handleSetChannelRssEnabled( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; enabled: boolean }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= MANUAL_CHANNEL_ID || - typeof data.enabled !== 'boolean' - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const saved = setChannelRssEnabled(data.channelId, data.enabled); - json(res, 200, { ok: true, saved, enabled: data.enabled }); -} - -export async function handleReorderChannel( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { channelId: number; direction: 'up' | 'down' }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if ( - !Number.isInteger(data.channelId) || - data.channelId <= 1 || - !['up', 'down'].includes(data.direction) - ) { - return json(res, 400, { error: 'invalid request' }); - } - - const moved = moveChannelOrder(data.channelId, data.direction); - json(res, 200, { ok: true, moved }); -} - -export async function handleReorderTag(req: IncomingMessage, res: ServerResponse): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { tag: string; direction: 'up' | 'down' }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - const tag = typeof data.tag === 'string' ? normalizeChannelTags(data.tag)[0] : ''; - if (!tag || tag !== data.tag || !['up', 'down'].includes(data.direction)) { - return json(res, 400, { error: 'invalid request' }); - } - - const moved = moveTagOrder(data.tag, data.direction); - json(res, 200, { ok: true, moved }); -} - -export async function handleDeleteTag(req: IncomingMessage, res: ServerResponse): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { tag: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - const tag = typeof data.tag === 'string' ? normalizeChannelTags(data.tag)[0] : ''; - if (!tag || tag !== data.tag) { - return json(res, 400, { error: 'invalid request' }); - } - - const deleted = deleteTag(data.tag); - json(res, 200, { ok: true, deleted, tag: data.tag }); -} - -export async function handleAdminDeleteVideoFiles( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { youtubeId: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); - - const videoDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/videos/${data.youtubeId}.mp4`); - const audioDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/audio/${data.youtubeId}.m4a`); - setMediaStatusesNone(data.youtubeId, { video: videoDeleted, audio: audioDeleted }); - - json(res, 200, { ok: true, videoDeleted, audioDeleted }); -} - -export async function handleAdminDeleteVideo( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { youtubeId: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); - - const videoDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/videos/${data.youtubeId}.mp4`); - const audioDeleted = await unlinkIfExists(`${config.MEDIA_DIR}/audio/${data.youtubeId}.m4a`); - const videoRemoved = deleteVideoByYoutubeId(data.youtubeId); - const jobsRemoved = deleteJobsByYoutubeId(data.youtubeId); - - json(res, 200, { ok: true, videoDeleted, audioDeleted, videoRemoved, jobsRemoved }); -} - -type ResetStatusResult = { youtubeId: string; statusType: 'video' | 'audio' }; -const RESETTABLE_MEDIA_STATUSES = new Set(['queued', 'downloading', 'expired']); - -function resetDownloadJobStatus(jobId: number): ResetStatusResult | null { - const job = getJobAdminById(jobId); - if (!job || job.status === 'done' || !job.youtubeId) return null; - - const video = getVideoByYoutubeId(job.youtubeId); - if (!video) return null; - - if (job.type === 'download_video') { - if (!RESETTABLE_MEDIA_STATUSES.has(video.videoStatus)) return null; - setVideoStatus(job.youtubeId, 'none'); - return { youtubeId: job.youtubeId, statusType: 'video' }; - } - - if (job.type === 'download_audio') { - if (!RESETTABLE_MEDIA_STATUSES.has(video.audioStatus)) return null; - setAudioStatus(job.youtubeId, 'none'); - return { youtubeId: job.youtubeId, statusType: 'audio' }; - } - - return null; -} - -export async function handleAdminDeleteJob( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { jobId: number }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!Number.isInteger(data.jobId) || data.jobId <= 0) { - return json(res, 400, { error: 'invalid request' }); - } - - const resetStatus = resetDownloadJobStatus(data.jobId); - const deleted = deleteJobById(data.jobId); - json(res, 200, { ok: true, deleted, resetStatus }); -} - -export async function handleAdminResetVideoStatus( - req: IncomingMessage, - res: ServerResponse, -): Promise { - if (!requireAdminApi(req, res)) return; - if (!checkCsrf(req, res)) return; - - let data: { youtubeId: string }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!isValidVideoId(data.youtubeId)) return json(res, 400, { error: 'invalid request' }); - - const video = getVideoByYoutubeId(data.youtubeId); - if (!video) return json(res, 404, { error: 'not found' }); - - const resetVideo = RESETTABLE_MEDIA_STATUSES.has(video.videoStatus); - const resetAudio = RESETTABLE_MEDIA_STATUSES.has(video.audioStatus); - setMediaStatusesNone(data.youtubeId, { video: resetVideo, audio: resetAudio }); - const jobsRemoved = deleteDownloadJobsByYoutubeId(data.youtubeId); - - json(res, 200, { - ok: true, - resetStatus: { youtubeId: data.youtubeId, resetVideo, resetAudio }, - jobsRemoved, - }); -} - -export async function handleAdminSetUserRole( - req: IncomingMessage, - res: ServerResponse, -): Promise { - const session = requireAdminApi(req, res); - if (!session) return; - if (!checkCsrf(req, res)) return; - - let data: { userId: number; role: UserRole }; - try { - data = JSON.parse(await readBody(req)); - } catch { - return json(res, 400, { error: 'invalid json' }); - } - - if (!Number.isInteger(data.userId) || !['user', 'admin'].includes(data.role)) { - return json(res, 400, { error: 'invalid request' }); - } - - if (data.userId === session.userId && data.role !== 'admin') { - return json(res, 403, { error: 'cannot change your own admin role' }); - } - - const saved = setUserRole(data.userId, data.role); - json(res, 200, { ok: true, saved }); -} +/** Compatibility facade for the split JSON API handlers. */ + +export { handleSidebarMode, handleStatus, handleSince, handleFeedCards } from './api-feed.ts'; +export { handleDownload, handlePlay } from './api-media.ts'; +export { + handleAddChannel, + handleAddVideo, + handleSetChannelDisplayName, + handleSetChannelTags, + handleSetChannelAutoDownload, + handleSetChannelGuestVisible, + handleSetChannelRssEnabled, + handleReorderChannel, + handleReorderTag, + handleDeleteTag, + handleAdminDeleteVideoFiles, + handleAdminDeleteVideo, + handleAdminDeleteJob, + handleAdminResetVideoStatus, + handleAdminSetUserRole, +} from './api-admin.ts'; diff --git a/src/handlers/video.ts b/src/handlers/video.ts index 88951fa..32f3d21 100644 --- a/src/handlers/video.ts +++ b/src/handlers/video.ts @@ -6,7 +6,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { config } from '../config.ts'; import { parseCookies } from '../lib/auth/cookies.ts'; import { canGuestAccessVideo, getGuestVisibleVideo } from '../lib/guest-access.ts'; -import { getVideoByUid } from '../lib/video.ts'; +import { getVideoByUid } from '../lib/video-queries.ts'; import { getOptionalSession, notFound, html, NO_STORE } from '../lib/http.ts'; import { renderPlayerPage } from '../pages/player.ts'; diff --git a/src/lib/video-mutations.ts b/src/lib/video-mutations.ts new file mode 100644 index 0000000..cd9cefa --- /dev/null +++ b/src/lib/video-mutations.ts @@ -0,0 +1,100 @@ +/** + * video-mutations.ts — write access layer for the videos table. + */ + +import { randomBytes } from 'node:crypto'; +import { db } from './db.ts'; +import { normalizeChapters, type VideoChapter, type VideoEntry } from './video-queries.ts'; + +const stmtExists = db.prepare(`SELECT id FROM videos WHERE youtube_id = ?`); +const stmtSetVideoStatus = db.prepare( + `UPDATE videos SET video_status = ?, ready_at = CASE WHEN ? = 'ready' THEN strftime('%Y-%m-%dT%H:%M:%SZ','now') ELSE ready_at END WHERE youtube_id = ?`, +); +const stmtSetAudioStatus = db.prepare( + `UPDATE videos SET audio_status = ?, ready_at = CASE WHEN ? = 'ready' THEN strftime('%Y-%m-%dT%H:%M:%SZ','now') ELSE ready_at END WHERE youtube_id = ?`, +); +const stmtSetDuration = db.prepare( + `UPDATE videos SET duration = ? WHERE youtube_id = ? AND duration = 0`, +); +const stmtSetChapters = db.prepare(`UPDATE videos SET chapters_json = ? WHERE youtube_id = ?`); +const stmtInsert = db.prepare(` + INSERT INTO videos (channel_id, uid, youtube_id, title, date, duration, source_type) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (youtube_id) DO NOTHING +`); +const stmtDeleteVideoByYoutubeId = db.prepare(`DELETE FROM videos WHERE youtube_id = ?`); +const stmtSetVideoNone = db.prepare(`UPDATE videos SET video_status = 'none' WHERE youtube_id = ?`); +const stmtSetAudioNone = db.prepare(`UPDATE videos SET audio_status = 'none' WHERE youtube_id = ?`); + +export function generateVideoUid(): string { + return randomBytes(12).toString('base64url'); +} + +export function videoExists(youtubeId: string): boolean { + return !!stmtExists.get(youtubeId); +} + +export function setVideoStatus(youtubeId: string, status: string): void { + stmtSetVideoStatus.run(status, status, youtubeId); +} + +export function setAudioStatus(youtubeId: string, status: string): void { + stmtSetAudioStatus.run(status, status, youtubeId); +} + +export function setDurationIfZero(youtubeId: string, duration: number): void { + stmtSetDuration.run(duration, youtubeId); +} + +export function setVideoChapters(youtubeId: string, chapters: VideoChapter[]): void { + stmtSetChapters.run(JSON.stringify(normalizeChapters(chapters)), youtubeId); +} + +export function insertVideos( + entries: VideoEntry[], + channelId: number, + sourceType: string, +): string[] { + const insertedYoutubeIds: string[] = []; + db.exec('BEGIN'); + try { + for (const e of entries) { + let uid = generateVideoUid(); + for (let attempt = 0; attempt < 5; attempt++) { + const result = stmtInsert.run( + channelId, + uid, + e.youtubeId, + e.title, + e.date, + e.duration ?? 0, + sourceType, + ); + if (result.changes > 0) { + insertedYoutubeIds.push(e.youtubeId); + break; + } + if (videoExists(e.youtubeId)) break; + uid = generateVideoUid(); + } + } + db.exec('COMMIT'); + return insertedYoutubeIds; + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } +} + +export function deleteVideoByYoutubeId(youtubeId: string): boolean { + const r = stmtDeleteVideoByYoutubeId.run(youtubeId); + return r.changes > 0; +} + +export function setMediaStatusesNone( + youtubeId: string, + opts: { video: boolean; audio: boolean }, +): void { + if (opts.video) stmtSetVideoNone.run(youtubeId); + if (opts.audio) stmtSetAudioNone.run(youtubeId); +} diff --git a/src/lib/video-queries.ts b/src/lib/video-queries.ts new file mode 100644 index 0000000..7371761 --- /dev/null +++ b/src/lib/video-queries.ts @@ -0,0 +1,467 @@ +/** + * video-queries.ts — read access layer for the videos table. + */ + +import { db } from './db.ts'; +import { FEED_TAG_ALL, normalizeGuestFeedTag } from './feed-tags.ts'; + +export const DEFAULT_VIDEO_PAGE_SIZE = 42; + +export interface VideoRow { + uid: string; + youtubeId: string; + title: string; + channelId: number; + channelName: string; + date: string; + duration: number; + videoStatus: string; + audioStatus: string; + chapters: VideoChapter[]; +} + +export interface VideoChapter { + title: string; + start: number; + end: number; +} + +export interface VideoEntry { + youtubeId: string; + title: string; + date: string; + duration?: number; +} + +export interface VideoStatusSummary { + status: string; + videoCount: number; + audioCount: number; +} + +export interface VideoStatusRow { + youtubeId: string; + title: string; + videoStatus: string; + audioStatus: string; + readyAt: string; + createdAt: string; +} + +export interface DownloadedVideoRow { + youtubeId: string; + title: string; + videoStatus: string; + audioStatus: string; + readyAt: string; + createdAt: string; +} + +export interface VideoPageQuery { + page: number; + pageSize: number; + tag?: string; + channelId?: number; +} + +export interface VideoPage { + items: VideoRow[]; + page: number; + pageSize: number; + pageCount: number; + total: number; +} + +export type PublicVideoRow = Omit; + +const SEL = ` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v LEFT JOIN channels c ON v.channel_id = c.id`; +const SEL_WITH_CHAPTERS = ` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + v.chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v LEFT JOIN channels c ON v.channel_id = c.id`; +const GUEST_VISIBLE_WHERE = `c.guest_visible = 1`; + +const stmtGetByYoutubeId = db.prepare(`${SEL_WITH_CHAPTERS} WHERE v.youtube_id = ?`); +const stmtGetByUid = db.prepare(`${SEL_WITH_CHAPTERS} WHERE v.uid = ?`); +const stmtCountAll = db.prepare(`SELECT COUNT(*) AS count FROM videos`); +const stmtGetAllPage = db.prepare( + `${SEL} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, +); +const stmtCountByChannel = db.prepare(`SELECT COUNT(*) AS count FROM videos WHERE channel_id = ?`); +const stmtGetByChannelPage = db.prepare( + `${SEL} WHERE v.channel_id = ? ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, +); +const stmtCountByTag = db.prepare(` + SELECT COUNT(*) AS count + FROM videos v + JOIN channel_tags ct ON ct.channel_id = v.channel_id + WHERE ct.tag = ?`); +const stmtGetByTagPage = db.prepare(` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v + JOIN channel_tags ct ON ct.channel_id = v.channel_id + JOIN channels c ON v.channel_id = c.id + WHERE ct.tag = ? + ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`); +const stmtCountByTagManual = db.prepare( + `SELECT COUNT(*) AS count FROM videos WHERE source_type = 'manual'`, +); +const stmtGetByTagManualPage = db.prepare(` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v JOIN channels c ON v.channel_id = c.id + WHERE v.source_type = 'manual' + ORDER BY v.created_at DESC, v.date DESC LIMIT ? OFFSET ?`); +const stmtGetSince = db.prepare( + `${SEL} WHERE v.created_at > ? ORDER BY v.date DESC, v.created_at DESC LIMIT 50`, +); +const stmtGetSinceByChannel = db.prepare( + `${SEL} WHERE v.created_at > ? AND v.channel_id = ? ORDER BY v.date DESC, v.created_at DESC LIMIT 50`, +); +const stmtGetSinceByTag = db.prepare(` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v + JOIN channel_tags ct ON ct.channel_id = v.channel_id + JOIN channels c ON v.channel_id = c.id + WHERE v.created_at > ? AND ct.tag = ? + ORDER BY v.date DESC, v.created_at DESC LIMIT 50`); +const stmtGetSinceByTagManual = db.prepare(` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v JOIN channels c ON v.channel_id = c.id + WHERE v.created_at > ? AND v.source_type = 'manual' + ORDER BY v.created_at DESC, v.date DESC LIMIT 50`); +const stmtGetSinceReady = db.prepare( + `${SEL} WHERE (v.video_status = 'ready' OR v.audio_status = 'ready') AND v.ready_at > ? ORDER BY v.ready_at DESC LIMIT 50`, +); +const stmtCountReady = db.prepare( + `SELECT COUNT(*) AS count FROM videos WHERE video_status = 'ready' OR audio_status = 'ready'`, +); +const stmtGetReadyPage = db.prepare( + `${SEL} WHERE v.video_status = 'ready' OR v.audio_status = 'ready' ORDER BY v.ready_at DESC LIMIT ? OFFSET ?`, +); +const stmtGuestCountAll = db.prepare(` + SELECT COUNT(*) AS count + FROM videos v JOIN channels c ON v.channel_id = c.id + WHERE ${GUEST_VISIBLE_WHERE}`); +const stmtGuestGetAllPage = db.prepare( + `${SEL} WHERE ${GUEST_VISIBLE_WHERE} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, +); +const stmtGuestCountByChannel = db.prepare(` + SELECT COUNT(*) AS count + FROM videos v JOIN channels c ON v.channel_id = c.id + WHERE v.channel_id = ? AND ${GUEST_VISIBLE_WHERE}`); +const stmtGuestGetByChannelPage = db.prepare( + `${SEL} WHERE v.channel_id = ? AND ${GUEST_VISIBLE_WHERE} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, +); +const stmtGuestCountByTag = db.prepare(` + SELECT COUNT(*) AS count + FROM videos v + JOIN channel_tags ct ON ct.channel_id = v.channel_id + JOIN channels c ON v.channel_id = c.id + WHERE ct.tag = ? AND ${GUEST_VISIBLE_WHERE}`); +const stmtGuestGetByTagPage = db.prepare(` + SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, + '[]' AS chapters_json, + COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name + FROM videos v + JOIN channel_tags ct ON ct.channel_id = v.channel_id + JOIN channels c ON v.channel_id = c.id + WHERE ct.tag = ? AND ${GUEST_VISIBLE_WHERE} + ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`); +const stmtGetStatusSummary = db.prepare(` + WITH s(status) AS (VALUES ('none'), ('queued'), ('downloading'), ('ready'), ('expired')) + SELECT + s.status AS status, + (SELECT COUNT(*) FROM videos WHERE video_status = s.status) AS video_count, + (SELECT COUNT(*) FROM videos WHERE audio_status = s.status) AS audio_count + FROM s +`); +const stmtGetProblemStatusRows = db.prepare(` + SELECT youtube_id, title, video_status, audio_status, + COALESCE(ready_at, '') AS ready_at, created_at + FROM videos + WHERE video_status IN ('queued', 'downloading', 'expired') + OR audio_status IN ('queued', 'downloading', 'expired') + ORDER BY created_at DESC + LIMIT ? +`); +const stmtGetDownloadedVideos = db.prepare(` + SELECT youtube_id, title, video_status, audio_status, + COALESCE(ready_at, '') AS ready_at, created_at + FROM videos + WHERE video_status = 'ready' OR audio_status = 'ready' + ORDER BY COALESCE(ready_at, created_at) DESC + LIMIT ? +`); + +type RawRow = { + uid: string; + youtube_id: string; + title: string; + channel_id: number; + channel_name: string; + date: string; + duration: number; + video_status: string; + audio_status: string; + chapters_json: string; +}; + +type RawVideoStatusSummary = { + status: string; + video_count: number; + audio_count: number; +}; + +type RawVideoStatusRow = { + youtube_id: string; + title: string; + video_status: string; + audio_status: string; + ready_at: string; + created_at: string; +}; + +type RawDownloadedVideoRow = { + youtube_id: string; + title: string; + video_status: string; + audio_status: string; + ready_at: string; + created_at: string; +}; + +type RawCountRow = { + count: number; +}; + +function toRow(r: RawRow): VideoRow { + return { + uid: r.uid, + youtubeId: r.youtube_id, + title: r.title, + channelId: r.channel_id, + channelName: r.channel_name, + date: r.date, + duration: r.duration, + videoStatus: r.video_status, + audioStatus: r.audio_status, + chapters: parseChaptersJson(r.chapters_json), + }; +} + +function clampPage(page: number, pageSize: number, total: number): number { + return Math.min(page, Math.max(1, Math.ceil(total / pageSize))); +} + +function offsetFor(page: number, pageSize: number): number { + return (page - 1) * pageSize; +} + +function pageResult(rows: RawRow[], page: number, pageSize: number, total: number): VideoPage { + return { + items: rows.map(toRow), + page, + pageSize, + pageCount: Math.max(1, Math.ceil(total / pageSize)), + total, + }; +} + +export function normalizeChapters(chapters: unknown): VideoChapter[] { + if (!Array.isArray(chapters)) return []; + + return chapters + .map((chapter) => { + if (!chapter || typeof chapter !== 'object') return null; + const row = chapter as { title?: unknown; start?: unknown; end?: unknown }; + const title = typeof row.title === 'string' ? row.title.trim() : ''; + const start = typeof row.start === 'number' ? row.start : Number(row.start); + const end = typeof row.end === 'number' ? row.end : Number(row.end); + if (!title || !Number.isFinite(start) || !Number.isFinite(end) || end <= start || start < 0) { + return null; + } + return { + title, + start: Math.floor(start), + end: Math.floor(end), + }; + }) + .filter((chapter): chapter is VideoChapter => chapter !== null); +} + +function parseChaptersJson(value: string): VideoChapter[] { + if (!value) return []; + try { + return normalizeChapters(JSON.parse(value)); + } catch { + return []; + } +} + +export function getVideoByYoutubeId(youtubeId: string): VideoRow | undefined { + const r = stmtGetByYoutubeId.get(youtubeId) as RawRow | undefined; + return r ? toRow(r) : undefined; +} + +export function getVideoByUid(uid: string): VideoRow | undefined { + const r = stmtGetByUid.get(uid) as RawRow | undefined; + return r ? toRow(r) : undefined; +} + +export function toPublicVideoRow(row: VideoRow): PublicVideoRow { + return { + uid: row.uid, + title: row.title, + channelId: row.channelId, + channelName: row.channelName, + date: row.date, + duration: row.duration, + videoStatus: row.videoStatus, + audioStatus: row.audioStatus, + }; +} + +export function getNewVideosSince(isoTimestamp: string): VideoRow[] { + return (stmtGetSince.all(isoTimestamp) as RawRow[]).map(toRow); +} + +export function getNewVideosSinceByChannel(isoTimestamp: string, channelId: number): VideoRow[] { + return (stmtGetSinceByChannel.all(isoTimestamp, channelId) as RawRow[]).map(toRow); +} + +export function getNewVideosSinceByTag(isoTimestamp: string, tag: string): VideoRow[] { + const rows = + tag === 'manual' + ? (stmtGetSinceByTagManual.all(isoTimestamp) as RawRow[]) + : (stmtGetSinceByTag.all(isoTimestamp, tag) as RawRow[]); + return rows.map(toRow); +} + +export function getNewReadyVideosSince(isoTimestamp: string): VideoRow[] { + return (stmtGetSinceReady.all(isoTimestamp) as RawRow[]).map(toRow); +} + +export function getVideoPage(query: VideoPageQuery): VideoPage { + const rawPageSize = Math.floor(query.pageSize); + const rawPage = Math.floor(query.page); + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? rawPageSize : 1; + const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; + const channelId = + typeof query.channelId === 'number' && Number.isInteger(query.channelId) ? query.channelId : 0; + const tag = (query.tag ?? 'all').trim() || 'all'; + + let total = 0; + let rows: RawRow[] = []; + + if (channelId > 0) { + total = (stmtCountByChannel.get(channelId) as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGetByChannelPage.all(channelId, pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + if (tag === 'ready') { + total = (stmtCountReady.get() as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGetReadyPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + if (tag === 'manual') { + total = (stmtCountByTagManual.get() as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGetByTagManualPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + if (tag !== 'all') { + total = (stmtCountByTag.get(tag) as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGetByTagPage.all(tag, pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + total = (stmtCountAll.get() as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGetAllPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); +} + +export function getGuestVideoPage(query: VideoPageQuery): VideoPage { + const rawPageSize = Math.floor(query.pageSize); + const rawPage = Math.floor(query.page); + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? rawPageSize : 1; + const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; + const channelId = + typeof query.channelId === 'number' && Number.isInteger(query.channelId) ? query.channelId : 0; + const tag = normalizeGuestFeedTag((query.tag ?? FEED_TAG_ALL).trim() || FEED_TAG_ALL); + + let total = 0; + let rows: RawRow[] = []; + + if (channelId > 0) { + total = (stmtGuestCountByChannel.get(channelId) as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGuestGetByChannelPage.all( + channelId, + pageSize, + offsetFor(page, pageSize), + ) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + if (tag !== FEED_TAG_ALL) { + total = (stmtGuestCountByTag.get(tag) as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGuestGetByTagPage.all(tag, pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); + } + + total = (stmtGuestCountAll.get() as RawCountRow).count; + const page = clampPage(requestedPage, pageSize, total); + rows = stmtGuestGetAllPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; + return pageResult(rows, page, pageSize, total); +} + +export function getVideoStatusSummary(): VideoStatusSummary[] { + return (stmtGetStatusSummary.all() as RawVideoStatusSummary[]).map((r) => ({ + status: r.status, + videoCount: r.video_count, + audioCount: r.audio_count, + })); +} + +export function getProblemStatusRows(limit = 200): VideoStatusRow[] { + return (stmtGetProblemStatusRows.all(limit) as RawVideoStatusRow[]).map((r) => ({ + youtubeId: r.youtube_id, + title: r.title, + videoStatus: r.video_status, + audioStatus: r.audio_status, + readyAt: r.ready_at, + createdAt: r.created_at, + })); +} + +export function getDownloadedVideos(limit = 300): DownloadedVideoRow[] { + return (stmtGetDownloadedVideos.all(limit) as RawDownloadedVideoRow[]).map((r) => ({ + youtubeId: r.youtube_id, + title: r.title, + videoStatus: r.video_status, + audioStatus: r.audio_status, + readyAt: r.ready_at, + createdAt: r.created_at, + })); +} diff --git a/src/lib/video.ts b/src/lib/video.ts index 81e27c2..16225c2 100644 --- a/src/lib/video.ts +++ b/src/lib/video.ts @@ -1,566 +1,41 @@ /** - * video.ts — data access layer for the videos table. + * video.ts — compatibility facade for video queries and mutations. */ -import { randomBytes } from 'node:crypto'; -import { db } from './db.ts'; -import { FEED_TAG_ALL, normalizeGuestFeedTag } from './feed-tags.ts'; - -export const DEFAULT_VIDEO_PAGE_SIZE = 42; - -export interface VideoRow { - uid: string; - youtubeId: string; - title: string; - channelId: number; - channelName: string; - date: string; - duration: number; - videoStatus: string; - audioStatus: string; - chapters: VideoChapter[]; -} - -export interface VideoChapter { - title: string; - start: number; - end: number; -} - -export interface VideoEntry { - youtubeId: string; - title: string; - date: string; - duration?: number; -} - -export interface VideoStatusSummary { - status: string; - videoCount: number; - audioCount: number; -} - -export interface VideoStatusRow { - youtubeId: string; - title: string; - videoStatus: string; - audioStatus: string; - readyAt: string; - createdAt: string; -} - -export interface DownloadedVideoRow { - youtubeId: string; - title: string; - videoStatus: string; - audioStatus: string; - readyAt: string; - createdAt: string; -} - -export interface VideoPageQuery { - page: number; - pageSize: number; - tag?: string; - channelId?: number; -} - -export interface VideoPage { - items: VideoRow[]; - page: number; - pageSize: number; - pageCount: number; - total: number; -} - -export type PublicVideoRow = Omit; - -// ── Statements ──────────────────────────────────────────────────────────────── - -const SEL = ` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v LEFT JOIN channels c ON v.channel_id = c.id`; -const SEL_WITH_CHAPTERS = ` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - v.chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v LEFT JOIN channels c ON v.channel_id = c.id`; -const GUEST_VISIBLE_WHERE = `c.guest_visible = 1`; - -const stmtGetByYoutubeId = db.prepare(`${SEL_WITH_CHAPTERS} WHERE v.youtube_id = ?`); -const stmtGetByUid = db.prepare(`${SEL_WITH_CHAPTERS} WHERE v.uid = ?`); -const stmtCountAll = db.prepare(`SELECT COUNT(*) AS count FROM videos`); -const stmtGetAllPage = db.prepare( - `${SEL} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, -); -const stmtCountByChannel = db.prepare(`SELECT COUNT(*) AS count FROM videos WHERE channel_id = ?`); -const stmtGetByChannelPage = db.prepare( - `${SEL} WHERE v.channel_id = ? ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, -); -const stmtCountByTag = db.prepare(` - SELECT COUNT(*) AS count - FROM videos v - JOIN channel_tags ct ON ct.channel_id = v.channel_id - WHERE ct.tag = ?`); -const stmtGetByTagPage = db.prepare(` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v - JOIN channel_tags ct ON ct.channel_id = v.channel_id - JOIN channels c ON v.channel_id = c.id - WHERE ct.tag = ? - ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`); -const stmtCountByTagManual = db.prepare( - `SELECT COUNT(*) AS count FROM videos WHERE source_type = 'manual'`, -); -const stmtGetByTagManualPage = db.prepare(` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v JOIN channels c ON v.channel_id = c.id - WHERE v.source_type = 'manual' - ORDER BY v.created_at DESC, v.date DESC LIMIT ? OFFSET ?`); -const stmtGetSince = db.prepare( - `${SEL} WHERE v.created_at > ? ORDER BY v.date DESC, v.created_at DESC LIMIT 50`, -); -const stmtGetSinceByChannel = db.prepare( - `${SEL} WHERE v.created_at > ? AND v.channel_id = ? ORDER BY v.date DESC, v.created_at DESC LIMIT 50`, -); -const stmtGetSinceByTag = db.prepare(` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v - JOIN channel_tags ct ON ct.channel_id = v.channel_id - JOIN channels c ON v.channel_id = c.id - WHERE v.created_at > ? AND ct.tag = ? - ORDER BY v.date DESC, v.created_at DESC LIMIT 50`); -const stmtGetSinceByTagManual = db.prepare(` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v JOIN channels c ON v.channel_id = c.id - WHERE v.created_at > ? AND v.source_type = 'manual' - ORDER BY v.created_at DESC, v.date DESC LIMIT 50`); -const stmtGetSinceReady = db.prepare( - `${SEL} WHERE (v.video_status = 'ready' OR v.audio_status = 'ready') AND v.ready_at > ? ORDER BY v.ready_at DESC LIMIT 50`, -); -const stmtCountReady = db.prepare( - `SELECT COUNT(*) AS count FROM videos WHERE video_status = 'ready' OR audio_status = 'ready'`, -); -const stmtGetReadyPage = db.prepare( - `${SEL} WHERE v.video_status = 'ready' OR v.audio_status = 'ready' ORDER BY v.ready_at DESC LIMIT ? OFFSET ?`, -); -const stmtGuestCountAll = db.prepare(` - SELECT COUNT(*) AS count - FROM videos v JOIN channels c ON v.channel_id = c.id - WHERE ${GUEST_VISIBLE_WHERE}`); -const stmtGuestGetAllPage = db.prepare( - `${SEL} WHERE ${GUEST_VISIBLE_WHERE} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, -); -const stmtGuestCountByChannel = db.prepare(` - SELECT COUNT(*) AS count - FROM videos v JOIN channels c ON v.channel_id = c.id - WHERE v.channel_id = ? AND ${GUEST_VISIBLE_WHERE}`); -const stmtGuestGetByChannelPage = db.prepare( - `${SEL} WHERE v.channel_id = ? AND ${GUEST_VISIBLE_WHERE} ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`, -); -const stmtGuestCountByTag = db.prepare(` - SELECT COUNT(*) AS count - FROM videos v - JOIN channel_tags ct ON ct.channel_id = v.channel_id - JOIN channels c ON v.channel_id = c.id - WHERE ct.tag = ? AND ${GUEST_VISIBLE_WHERE}`); -const stmtGuestGetByTagPage = db.prepare(` - SELECT v.uid, v.youtube_id, v.title, v.channel_id, v.date, v.duration, v.video_status, v.audio_status, - '[]' AS chapters_json, - COALESCE(NULLIF(c.display_name,''), c.name, '') AS channel_name - FROM videos v - JOIN channel_tags ct ON ct.channel_id = v.channel_id - JOIN channels c ON v.channel_id = c.id - WHERE ct.tag = ? AND ${GUEST_VISIBLE_WHERE} - ORDER BY v.date DESC, v.created_at DESC LIMIT ? OFFSET ?`); -const stmtExists = db.prepare(`SELECT id FROM videos WHERE youtube_id = ?`); -const stmtSetVideoStatus = db.prepare( - `UPDATE videos SET video_status = ?, ready_at = CASE WHEN ? = 'ready' THEN strftime('%Y-%m-%dT%H:%M:%SZ','now') ELSE ready_at END WHERE youtube_id = ?`, -); -const stmtSetAudioStatus = db.prepare( - `UPDATE videos SET audio_status = ?, ready_at = CASE WHEN ? = 'ready' THEN strftime('%Y-%m-%dT%H:%M:%SZ','now') ELSE ready_at END WHERE youtube_id = ?`, -); -const stmtSetDuration = db.prepare( - `UPDATE videos SET duration = ? WHERE youtube_id = ? AND duration = 0`, -); -const stmtSetChapters = db.prepare(`UPDATE videos SET chapters_json = ? WHERE youtube_id = ?`); -const stmtInsert = db.prepare(` - INSERT INTO videos (channel_id, uid, youtube_id, title, date, duration, source_type) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (youtube_id) DO NOTHING -`); -const stmtGetStatusSummary = db.prepare(` - WITH s(status) AS (VALUES ('none'), ('queued'), ('downloading'), ('ready'), ('expired')) - SELECT - s.status AS status, - (SELECT COUNT(*) FROM videos WHERE video_status = s.status) AS video_count, - (SELECT COUNT(*) FROM videos WHERE audio_status = s.status) AS audio_count - FROM s -`); -const stmtGetProblemStatusRows = db.prepare(` - SELECT youtube_id, title, video_status, audio_status, - COALESCE(ready_at, '') AS ready_at, created_at - FROM videos - WHERE video_status IN ('queued', 'downloading', 'expired') - OR audio_status IN ('queued', 'downloading', 'expired') - ORDER BY created_at DESC - LIMIT ? -`); -const stmtGetDownloadedVideos = db.prepare(` - SELECT youtube_id, title, video_status, audio_status, - COALESCE(ready_at, '') AS ready_at, created_at - FROM videos - WHERE video_status = 'ready' OR audio_status = 'ready' - ORDER BY COALESCE(ready_at, created_at) DESC - LIMIT ? -`); -const stmtDeleteVideoByYoutubeId = db.prepare(`DELETE FROM videos WHERE youtube_id = ?`); -const stmtSetVideoNone = db.prepare(`UPDATE videos SET video_status = 'none' WHERE youtube_id = ?`); -const stmtSetAudioNone = db.prepare(`UPDATE videos SET audio_status = 'none' WHERE youtube_id = ?`); - -// ── Internal ────────────────────────────────────────────────────────────────── - -type RawRow = { - uid: string; - youtube_id: string; - title: string; - channel_id: number; - channel_name: string; - date: string; - duration: number; - video_status: string; - audio_status: string; - chapters_json: string; -}; - -type RawVideoStatusSummary = { - status: string; - video_count: number; - audio_count: number; -}; - -type RawVideoStatusRow = { - youtube_id: string; - title: string; - video_status: string; - audio_status: string; - ready_at: string; - created_at: string; -}; - -type RawDownloadedVideoRow = { - youtube_id: string; - title: string; - video_status: string; - audio_status: string; - ready_at: string; - created_at: string; -}; - -type RawCountRow = { - count: number; -}; - -export function generateVideoUid(): string { - return randomBytes(12).toString('base64url'); -} - -function toRow(r: RawRow): VideoRow { - return { - uid: r.uid, - youtubeId: r.youtube_id, - title: r.title, - channelId: r.channel_id, - channelName: r.channel_name, - date: r.date, - duration: r.duration, - videoStatus: r.video_status, - audioStatus: r.audio_status, - chapters: parseChaptersJson(r.chapters_json), - }; -} - -function clampPage(page: number, pageSize: number, total: number): number { - return Math.min(page, Math.max(1, Math.ceil(total / pageSize))); -} - -function offsetFor(page: number, pageSize: number): number { - return (page - 1) * pageSize; -} - -function pageResult(rows: RawRow[], page: number, pageSize: number, total: number): VideoPage { - return { - items: rows.map(toRow), - page, - pageSize, - pageCount: Math.max(1, Math.ceil(total / pageSize)), - total, - }; -} - -function normalizeChapters(chapters: unknown): VideoChapter[] { - if (!Array.isArray(chapters)) return []; - - return chapters - .map((chapter) => { - if (!chapter || typeof chapter !== 'object') return null; - const row = chapter as { title?: unknown; start?: unknown; end?: unknown }; - const title = typeof row.title === 'string' ? row.title.trim() : ''; - const start = typeof row.start === 'number' ? row.start : Number(row.start); - const end = typeof row.end === 'number' ? row.end : Number(row.end); - if (!title || !Number.isFinite(start) || !Number.isFinite(end) || end <= start || start < 0) { - return null; - } - return { - title, - start: Math.floor(start), - end: Math.floor(end), - }; - }) - .filter((chapter): chapter is VideoChapter => chapter !== null); -} - -function parseChaptersJson(value: string): VideoChapter[] { - if (!value) return []; - try { - return normalizeChapters(JSON.parse(value)); - } catch { - return []; - } -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -export function getVideoByYoutubeId(youtubeId: string): VideoRow | undefined { - const r = stmtGetByYoutubeId.get(youtubeId) as RawRow | undefined; - return r ? toRow(r) : undefined; -} - -export function getVideoByUid(uid: string): VideoRow | undefined { - const r = stmtGetByUid.get(uid) as RawRow | undefined; - return r ? toRow(r) : undefined; -} - -export function toPublicVideoRow(row: VideoRow): PublicVideoRow { - return { - uid: row.uid, - title: row.title, - channelId: row.channelId, - channelName: row.channelName, - date: row.date, - duration: row.duration, - videoStatus: row.videoStatus, - audioStatus: row.audioStatus, - }; -} - -export function getNewVideosSince(isoTimestamp: string): VideoRow[] { - return (stmtGetSince.all(isoTimestamp) as RawRow[]).map(toRow); -} - -export function getNewVideosSinceByChannel(isoTimestamp: string, channelId: number): VideoRow[] { - return (stmtGetSinceByChannel.all(isoTimestamp, channelId) as RawRow[]).map(toRow); -} - -export function getNewVideosSinceByTag(isoTimestamp: string, tag: string): VideoRow[] { - const rows = - tag === 'manual' - ? (stmtGetSinceByTagManual.all(isoTimestamp) as RawRow[]) - : (stmtGetSinceByTag.all(isoTimestamp, tag) as RawRow[]); - return rows.map(toRow); -} - -export function getNewReadyVideosSince(isoTimestamp: string): VideoRow[] { - return (stmtGetSinceReady.all(isoTimestamp) as RawRow[]).map(toRow); -} - -export function getVideoPage(query: VideoPageQuery): VideoPage { - const rawPageSize = Math.floor(query.pageSize); - const rawPage = Math.floor(query.page); - const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? rawPageSize : 1; - const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; - const channelId = - typeof query.channelId === 'number' && Number.isInteger(query.channelId) ? query.channelId : 0; - const tag = (query.tag ?? 'all').trim() || 'all'; - - let total = 0; - let rows: RawRow[] = []; - - if (channelId > 0) { - total = (stmtCountByChannel.get(channelId) as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGetByChannelPage.all(channelId, pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - if (tag === 'ready') { - total = (stmtCountReady.get() as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGetReadyPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - if (tag === 'manual') { - total = (stmtCountByTagManual.get() as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGetByTagManualPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - if (tag !== 'all') { - total = (stmtCountByTag.get(tag) as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGetByTagPage.all(tag, pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - total = (stmtCountAll.get() as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGetAllPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); -} - -export function getGuestVideoPage(query: VideoPageQuery): VideoPage { - const rawPageSize = Math.floor(query.pageSize); - const rawPage = Math.floor(query.page); - const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? rawPageSize : 1; - const requestedPage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; - const channelId = - typeof query.channelId === 'number' && Number.isInteger(query.channelId) ? query.channelId : 0; - const tag = normalizeGuestFeedTag((query.tag ?? FEED_TAG_ALL).trim() || FEED_TAG_ALL); - - let total = 0; - let rows: RawRow[] = []; - - if (channelId > 0) { - total = (stmtGuestCountByChannel.get(channelId) as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGuestGetByChannelPage.all( - channelId, - pageSize, - offsetFor(page, pageSize), - ) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - if (tag !== FEED_TAG_ALL) { - total = (stmtGuestCountByTag.get(tag) as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGuestGetByTagPage.all(tag, pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); - } - - total = (stmtGuestCountAll.get() as RawCountRow).count; - const page = clampPage(requestedPage, pageSize, total); - rows = stmtGuestGetAllPage.all(pageSize, offsetFor(page, pageSize)) as RawRow[]; - return pageResult(rows, page, pageSize, total); -} - -export function videoExists(youtubeId: string): boolean { - return !!stmtExists.get(youtubeId); -} - -export function setVideoStatus(youtubeId: string, status: string): void { - stmtSetVideoStatus.run(status, status, youtubeId); -} - -export function setAudioStatus(youtubeId: string, status: string): void { - stmtSetAudioStatus.run(status, status, youtubeId); -} - -export function setDurationIfZero(youtubeId: string, duration: number): void { - stmtSetDuration.run(duration, youtubeId); -} - -export function setVideoChapters(youtubeId: string, chapters: VideoChapter[]): void { - stmtSetChapters.run(JSON.stringify(normalizeChapters(chapters)), youtubeId); -} - -export function insertVideos( - entries: VideoEntry[], - channelId: number, - sourceType: string, -): string[] { - const insertedYoutubeIds: string[] = []; - db.exec('BEGIN'); - try { - for (const e of entries) { - let uid = generateVideoUid(); - for (let attempt = 0; attempt < 5; attempt++) { - const result = stmtInsert.run( - channelId, - uid, - e.youtubeId, - e.title, - e.date, - e.duration ?? 0, - sourceType, - ); - if (result.changes > 0) { - insertedYoutubeIds.push(e.youtubeId); - break; - } - if (videoExists(e.youtubeId)) break; - uid = generateVideoUid(); - } - } - db.exec('COMMIT'); - return insertedYoutubeIds; - } catch (err) { - db.exec('ROLLBACK'); - throw err; - } -} - -export function getVideoStatusSummary(): VideoStatusSummary[] { - return (stmtGetStatusSummary.all() as RawVideoStatusSummary[]).map((r) => ({ - status: r.status, - videoCount: r.video_count, - audioCount: r.audio_count, - })); -} - -export function getProblemStatusRows(limit = 200): VideoStatusRow[] { - return (stmtGetProblemStatusRows.all(limit) as RawVideoStatusRow[]).map((r) => ({ - youtubeId: r.youtube_id, - title: r.title, - videoStatus: r.video_status, - audioStatus: r.audio_status, - readyAt: r.ready_at, - createdAt: r.created_at, - })); -} - -export function getDownloadedVideos(limit = 300): DownloadedVideoRow[] { - return (stmtGetDownloadedVideos.all(limit) as RawDownloadedVideoRow[]).map((r) => ({ - youtubeId: r.youtube_id, - title: r.title, - videoStatus: r.video_status, - audioStatus: r.audio_status, - readyAt: r.ready_at, - createdAt: r.created_at, - })); -} - -export function deleteVideoByYoutubeId(youtubeId: string): boolean { - const r = stmtDeleteVideoByYoutubeId.run(youtubeId); - return r.changes > 0; -} - -export function setMediaStatusesNone( - youtubeId: string, - opts: { video: boolean; audio: boolean }, -): void { - if (opts.video) stmtSetVideoNone.run(youtubeId); - if (opts.audio) stmtSetAudioNone.run(youtubeId); -} +export { + DEFAULT_VIDEO_PAGE_SIZE, + getDownloadedVideos, + getGuestVideoPage, + getNewReadyVideosSince, + getNewVideosSince, + getNewVideosSinceByChannel, + getNewVideosSinceByTag, + getProblemStatusRows, + getVideoByUid, + getVideoByYoutubeId, + getVideoPage, + getVideoStatusSummary, + toPublicVideoRow, +} from './video-queries.ts'; +export type { + DownloadedVideoRow, + PublicVideoRow, + VideoChapter, + VideoEntry, + VideoPage, + VideoPageQuery, + VideoRow, + VideoStatusRow, + VideoStatusSummary, +} from './video-queries.ts'; +export { + deleteVideoByYoutubeId, + generateVideoUid, + insertVideos, + setAudioStatus, + setDurationIfZero, + setMediaStatusesNone, + setVideoChapters, + setVideoStatus, + videoExists, +} from './video-mutations.ts'; diff --git a/src/parts/admin-page/baker.ts b/src/parts/admin-page/baker.ts index d50ca0d..05ba7cf 100644 --- a/src/parts/admin-page/baker.ts +++ b/src/parts/admin-page/baker.ts @@ -8,7 +8,7 @@ import { getDownloadedVideos, getProblemStatusRows, getVideoStatusSummary, -} from '../../lib/video.ts'; +} from '../../lib/video-queries.ts'; import { t } from '../../pages/lang.ts'; interface AdminBakeContext { diff --git a/src/parts/feed-card-pager/baker.ts b/src/parts/feed-card-pager/baker.ts index f57aa16..1084c60 100644 --- a/src/parts/feed-card-pager/baker.ts +++ b/src/parts/feed-card-pager/baker.ts @@ -3,7 +3,7 @@ import { getGuestVideoPage, getVideoPage, toPublicVideoRow, -} from '../../lib/video.ts'; +} from '../../lib/video-queries.ts'; import type { ViewerMode } from '../../lib/guest-access.ts'; import { t } from '../../pages/lang.ts'; diff --git a/src/parts/feed-card-pager/handlers.js b/src/parts/feed-card-pager/handlers.js index 393bfc7..21dc945 100644 --- a/src/parts/feed-card-pager/handlers.js +++ b/src/parts/feed-card-pager/handlers.js @@ -232,7 +232,7 @@ export default { const btn = event.target.closest('[data-action="page"]'); const page = normalizePage(btn.dataset.page, part.state.page); if (page === part.state.page || part.state.loading) return; - loadPage(part, page).catch(() => {}); + void loadPage(part, page); }, 'click [data-action="download"]': async (part, event) => { const btn = event.target.closest('[data-action="download"]'); @@ -306,12 +306,12 @@ export default { }, onMount: (part) => { part.private.onPopState = () => { - loadPage(part, pageFromUrl(), { updateUrl: false }).catch(() => {}); + void loadPage(part, pageFromUrl(), { updateUrl: false }); }; window.addEventListener('popstate', part.private.onPopState); const initialUrlPage = pageFromUrl(); if (initialUrlPage !== part.state.page) { - loadPage(part, initialUrlPage, { updateUrl: false }).catch(() => {}); + void loadPage(part, initialUrlPage, { updateUrl: false }); } else { restartPolling(part, part.state.cards); } diff --git a/src/parts/media-queue/handlers.js b/src/parts/media-queue/handlers.js index f4094d2..aec8f61 100644 --- a/src/parts/media-queue/handlers.js +++ b/src/parts/media-queue/handlers.js @@ -94,7 +94,7 @@ function schedulePoll(part, delay) { clearTimeout(part.private.pollTimer); part.private.pollTimer = setTimeout(() => { part.private.pollTimer = null; - poll(part).catch(() => {}); + void poll(part); }, delay); } @@ -118,9 +118,14 @@ async function poll(part) { changed = true; return { ...item, status }; }); + part.private.pollErrorReported = false; if (changed) part.set('items', items); } catch (error) { if (error?.name === 'AbortError') return; + if (!part.private.pollErrorReported) { + console.warn('Media queue polling failed; retrying', error); + part.private.pollErrorReported = true; + } } finally { part.private.pollAbort = null; if (!part.private.destroyed && part.state.open && pendingIds(part).length) { diff --git a/src/parts/player-page/baker.ts b/src/parts/player-page/baker.ts index 6c582e7..725bbe2 100644 --- a/src/parts/player-page/baker.ts +++ b/src/parts/player-page/baker.ts @@ -1,4 +1,4 @@ -import { getVideoByUid } from '../../lib/video.ts'; +import { getVideoByUid } from '../../lib/video-queries.ts'; import { isGuestVisibleChannel } from '../../lib/guest-access.ts'; import { t } from '../../pages/lang.ts'; diff --git a/src/parts/player-page/handlers.js b/src/parts/player-page/handlers.js index 78272b3..d8f9c5d 100644 --- a/src/parts/player-page/handlers.js +++ b/src/parts/player-page/handlers.js @@ -25,6 +25,16 @@ const RESUME_MIN_SECONDS = 5; const RESUME_END_MARGIN_SECONDS = 10; const RESUME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +function reportAsyncError(part, flag, message, error) { + if (part.private[flag]) return; + part.private[flag] = true; + console.warn(`[player-page] ${message}`, error); +} + +function isExpectedBrowserError(error) { + return error?.name === 'AbortError' || error?.name === 'NotAllowedError'; +} + function readResumeTime(part) { try { const raw = localStorage.getItem(part.state.resumeKey); @@ -39,7 +49,8 @@ function readResumeTime(part) { return 0; } return time; - } catch { + } catch (error) { + reportAsyncError(part, 'resumeStorageErrorReported', 'resume storage is unavailable', error); return 0; } } @@ -60,13 +71,17 @@ function saveResumeTime(part) { part.state.resumeKey, JSON.stringify({ time: part.refs.media.currentTime, updatedAt: Date.now() }), ); - } catch {} + } catch (error) { + reportAsyncError(part, 'resumeStorageErrorReported', 'resume storage is unavailable', error); + } } function clearResumeTime(part) { try { localStorage.removeItem(part.state.resumeKey); - } catch {} + } catch (error) { + reportAsyncError(part, 'resumeStorageErrorReported', 'resume storage is unavailable', error); + } } function restoreResumeTime(part) { @@ -92,11 +107,34 @@ function syncPlayerProgress(part) { function recordFirstPlay(part) { if (part.private.playRecorded) return; part.private.playRecorded = true; - fetch('/api/play', { + void fetch('/api/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uid: part.state.uid, kind: part.state.kind }), - }).catch(() => {}); + }) + .then((res) => { + if (!res.ok) throw new Error(`play event request failed: ${res.status}`); + part.private.playRecordErrorReported = false; + }) + .catch((error) => { + reportAsyncError(part, 'playRecordErrorReported', 'play event recording failed', error); + }); +} + +function tryPlay(part) { + try { + Promise.resolve(part.refs.media.play()).catch((error) => { + if (!part.private.destroyed) part.set('paused', part.refs.media.paused); + if (!isExpectedBrowserError(error)) { + reportAsyncError(part, 'mediaPlayErrorReported', 'media play failed', error); + } + }); + } catch (error) { + if (!part.private.destroyed) part.set('paused', part.refs.media.paused); + if (!isExpectedBrowserError(error)) { + reportAsyncError(part, 'mediaPlayErrorReported', 'media play failed', error); + } + } } function syncSleepTimer(part) { @@ -164,7 +202,10 @@ export default { part.set('shareStatus', 'sharing'); try { await navigator.share({ title: part.state.title, url: location.href }); - } catch { + } catch (error) { + if (!isExpectedBrowserError(error)) { + reportAsyncError(part, 'shareErrorReported', 'native sharing failed', error); + } // Closing the native share menu is not an error that needs UI feedback. } if (!part.private.destroyed) part.set('shareStatus', 'idle'); @@ -176,9 +217,12 @@ export default { await navigator.clipboard.writeText(location.href); if (part.private.destroyed) return; part.set('shareStatus', 'copied'); - } catch { + } catch (error) { if (part.private.destroyed) return; part.set('shareStatus', 'idle'); + if (!isExpectedBrowserError(error)) { + reportAsyncError(part, 'clipboardErrorReported', 'clipboard sharing failed', error); + } return; } clearTimeout(part.private.shareStatusTimer); @@ -211,14 +255,14 @@ export default { eventChapterSeek: (part) => { part.refs.media.currentTime = Math.max(0, part.state.chapterSeekTime); syncPlayerProgress(part); - part.refs.media.play().catch(() => {}); + tryPlay(part); }, eventResume: (part) => { part.refs.media.currentTime = Math.max(0, part.state.resumeTime); syncActiveChapter(part); }, eventPlay: (part) => { - if (part.refs.media.paused) part.refs.media.play(); + if (part.refs.media.paused) tryPlay(part); else part.refs.media.pause(); }, paused: (part, value) => { diff --git a/src/server.ts b/src/server.ts index f469e30..b7c3fb9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,12 +24,14 @@ import { handleMediaAudio, handleThumb, } from './handlers/video.ts'; +import { handleDownload, handlePlay } from './handlers/api-media.ts'; import { - handleDownload, - handlePlay, handleSidebarMode, handleStatus, handleSince, + handleFeedCards, +} from './handlers/api-feed.ts'; +import { handleAddChannel, handleAddVideo, handleSetChannelDisplayName, @@ -45,8 +47,7 @@ import { handleAdminDeleteJob, handleAdminResetVideoStatus, handleAdminSetUserRole, - handleFeedCards, -} from './handlers/api.ts'; +} from './handlers/api-admin.ts'; type RouteMethod = 'get' | 'post'; diff --git a/src/worker-jobs.ts b/src/worker-jobs.ts new file mode 100644 index 0000000..a25f944 --- /dev/null +++ b/src/worker-jobs.ts @@ -0,0 +1,107 @@ +import type { WorkerRuntimeDependencies } from './worker-types.ts'; + +export function enqueueThumbsFor( + dependencies: WorkerRuntimeDependencies, + youtubeIds: string[], +): void { + for (const youtubeId of youtubeIds) { + dependencies.enqueue('download_thumbnail', { youtubeId }); + } +} + +export function enqueueAutoDownloadsFor( + dependencies: WorkerRuntimeDependencies, + youtubeIds: string[], + settings: { autoDownloadVideo: boolean; autoDownloadAudio: boolean }, +): void { + for (const youtubeId of youtubeIds) { + if (settings.autoDownloadVideo) { + dependencies.setVideoStatus(youtubeId, 'queued'); + dependencies.enqueue('download_video', { youtubeId }); + } + if (settings.autoDownloadAudio) { + dependencies.setAudioStatus(youtubeId, 'queued'); + dependencies.enqueue('download_audio', { youtubeId }); + } + } +} + +async function refreshChaptersFor( + dependencies: WorkerRuntimeDependencies, + youtubeId: string, +): Promise { + try { + dependencies.setVideoChapters(youtubeId, await dependencies.fetchChapters(youtubeId)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + dependencies.error(`chapter fetch failed for ${youtubeId}:`, msg); + } +} + +export async function processWorkerJob( + dependencies: WorkerRuntimeDependencies, + type: string, + payload: string, +): Promise { + const data = JSON.parse(payload); + + switch (type) { + case 'download_video': { + dependencies.setVideoStatus(data.youtubeId, 'downloading'); + try { + const duration = await dependencies.downloadVideo( + data.youtubeId, + `${dependencies.mediaDir}/videos`, + ); + dependencies.setVideoStatus(data.youtubeId, 'ready'); + if (duration > 0) dependencies.setDurationIfZero(data.youtubeId, duration); + await refreshChaptersFor(dependencies, data.youtubeId); + } catch (err) { + dependencies.setVideoStatus(data.youtubeId, 'none'); + throw err; + } + break; + } + + case 'download_audio': { + dependencies.setAudioStatus(data.youtubeId, 'downloading'); + try { + const duration = await dependencies.downloadAudio( + data.youtubeId, + `${dependencies.mediaDir}/audio`, + ); + dependencies.setAudioStatus(data.youtubeId, 'ready'); + if (duration > 0) dependencies.setDurationIfZero(data.youtubeId, duration); + await refreshChaptersFor(dependencies, data.youtubeId); + } catch (err) { + dependencies.setAudioStatus(data.youtubeId, 'none'); + throw err; + } + break; + } + + case 'crawl_channel': { + const result = await dependencies.crawlChannel(data.url, 1, dependencies.crawlInitial); + const insertedYoutubeIds = dependencies.insertVideos( + result.entries, + data.channelId, + 'channel', + ); + enqueueThumbsFor(dependencies, insertedYoutubeIds); + const channel = dependencies.getChannelById(data.channelId); + if (channel) enqueueAutoDownloadsFor(dependencies, insertedYoutubeIds, channel); + if (result.channelYoutubeId) { + dependencies.updateChannelYoutubeId(data.channelId, result.channelYoutubeId); + } + break; + } + + case 'download_thumbnail': { + const destDir = `${dependencies.mediaDir}/thumbs`; + if (!dependencies.existsSync(`${destDir}/${data.youtubeId}.jpg`)) { + await dependencies.downloadThumb(data.youtubeId, destDir); + } + break; + } + } +} diff --git a/src/worker-rss.ts b/src/worker-rss.ts new file mode 100644 index 0000000..16c1c76 --- /dev/null +++ b/src/worker-rss.ts @@ -0,0 +1,27 @@ +import type { WorkerRuntimeDependencies } from './worker-types.ts'; +import { enqueueAutoDownloadsFor, enqueueThumbsFor } from './worker-jobs.ts'; + +const RSS_CHANNEL_DELAY_MS = 1500; + +export async function pollRss( + dependencies: WorkerRuntimeDependencies, + shouldStop: () => boolean, +): Promise { + const channels = dependencies.getRssChannels(); + for (let index = 0; index < channels.length; index++) { + if (shouldStop()) break; + const channel = channels[index]; + try { + const entries = await dependencies.fetchFeed(channel.youtubeChannelId); + const insertedYoutubeIds = dependencies.insertVideos(entries, channel.id, 'channel'); + enqueueThumbsFor(dependencies, insertedYoutubeIds); + enqueueAutoDownloadsFor(dependencies, insertedYoutubeIds, channel); + dependencies.updateLastCrawled(channel.id); + } catch (err) { + dependencies.error(`RSS poll failed for channel ${channel.id}:`, err); + } + if (!shouldStop() && index < channels.length - 1) { + await dependencies.sleep(RSS_CHANNEL_DELAY_MS); + } + } +} diff --git a/src/worker-types.ts b/src/worker-types.ts new file mode 100644 index 0000000..e291c05 --- /dev/null +++ b/src/worker-types.ts @@ -0,0 +1,64 @@ +import type { DeletedFile } from './lib/disk.ts'; +import type { JobType } from './lib/queue.ts'; +import type { VideoChapter, VideoEntry } from './lib/video-queries.ts'; + +export type WorkerJob = { + id: number; + type: string; + payload: string; +}; + +export type ChannelDownloadSettings = { + autoDownloadVideo: boolean; + autoDownloadAudio: boolean; +}; + +export type RssChannel = ChannelDownloadSettings & { + id: number; + youtubeChannelId: string; +}; + +export interface WorkerRuntimeDependencies { + mediaDir: string; + crawlInitial: number; + enqueue(type: JobType, payload: Record): void; + take(): WorkerJob | undefined; + complete(id: number): void; + fail(id: number, error: string): void; + resetStale(): void; + downloadVideo(youtubeId: string, destDir: string): Promise; + downloadAudio(youtubeId: string, destDir: string): Promise; + downloadThumb(youtubeId: string, destDir: string): Promise; + crawlChannel( + channelUrl: string, + start: number, + end: number, + ): Promise<{ channelYoutubeId: string; entries: VideoEntry[] }>; + fetchChapters(youtubeId: string): Promise; + fetchFeed(youtubeChannelId: string): Promise; + existsSync(path: string): boolean; + checkDisk(onDeleted: (file: DeletedFile) => void): Promise; + purgeExpired(): void; + setVideoStatus(youtubeId: string, status: string): void; + setAudioStatus(youtubeId: string, status: string): void; + setDurationIfZero(youtubeId: string, duration: number): void; + insertVideos(entries: VideoEntry[], channelId: number, sourceType: string): string[]; + setVideoChapters(youtubeId: string, chapters: VideoChapter[]): void; + getChannelById(channelId: number): ChannelDownloadSettings | undefined; + getRssChannels(): RssChannel[]; + updateChannelYoutubeId(channelId: number, youtubeChannelId: string): void; + updateLastCrawled(channelId: number): void; + setInterval(handler: () => void, timeout: number): unknown; + clearInterval(id: unknown): void; + sleep(ms: number): Promise; + log(...args: unknown[]): void; + error(...args: unknown[]): void; + fatal(error: unknown): void; +} + +export interface WorkerRuntime { + start(): void; + stop(signal: string): Promise; + runNextJob(): Promise; + startRssPoll(): Promise | undefined; +} diff --git a/src/worker.ts b/src/worker.ts index aea9f92..4fa4e8f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8,7 +8,6 @@ import { existsSync } from 'node:fs'; import { config } from './config.ts'; import { enqueue, take, complete, fail, resetStale } from './lib/queue.ts'; -import type { JobType } from './lib/queue.ts'; import { downloadVideo, downloadAudio, @@ -25,94 +24,23 @@ import { setDurationIfZero, insertVideos, setVideoChapters, -} from './lib/video.ts'; +} from './lib/video-mutations.ts'; import { getChannelById, getRssChannels, updateChannelYoutubeId, updateLastCrawled, } from './lib/channel.ts'; +import { processWorkerJob } from './worker-jobs.ts'; +import { pollRss } from './worker-rss.ts'; +import type { WorkerRuntimeDependencies, WorkerRuntime } from './worker-types.ts'; +export type { WorkerRuntimeDependencies, WorkerRuntime } from './worker-types.ts'; const POLL_INTERVAL_MS = 2000; const RSS_INTERVAL_MS = 30 * 60 * 1000; -const RSS_CHANNEL_DELAY_MS = 1500; const DISK_CHECK_INTERVAL_MS = 5 * 60 * 1000; const SESSION_PURGE_INTERVAL_MS = 60 * 60 * 1000; -type WorkerJob = { - id: number; - type: string; - payload: string; -}; - -type VideoEntry = { - youtubeId: string; - title: string; - date: string; - duration?: number; -}; - -type VideoChapter = { - title: string; - start: number; - end: number; -}; - -type ChannelDownloadSettings = { - autoDownloadVideo: boolean; - autoDownloadAudio: boolean; -}; - -type RssChannel = ChannelDownloadSettings & { - id: number; - youtubeChannelId: string; -}; - -export interface WorkerRuntimeDependencies { - mediaDir: string; - crawlInitial: number; - enqueue(type: JobType, payload: Record): void; - take(): WorkerJob | undefined; - complete(id: number): void; - fail(id: number, error: string): void; - resetStale(): void; - downloadVideo(youtubeId: string, destDir: string): Promise; - downloadAudio(youtubeId: string, destDir: string): Promise; - downloadThumb(youtubeId: string, destDir: string): Promise; - crawlChannel( - channelUrl: string, - start: number, - end: number, - ): Promise<{ channelYoutubeId: string; entries: VideoEntry[] }>; - fetchChapters(youtubeId: string): Promise; - fetchFeed(youtubeChannelId: string): Promise; - existsSync(path: string): boolean; - checkDisk(onDeleted: (file: DeletedFile) => void): Promise; - purgeExpired(): void; - setVideoStatus(youtubeId: string, status: string): void; - setAudioStatus(youtubeId: string, status: string): void; - setDurationIfZero(youtubeId: string, duration: number): void; - insertVideos(entries: VideoEntry[], channelId: number, sourceType: string): string[]; - setVideoChapters(youtubeId: string, chapters: VideoChapter[]): void; - getChannelById(channelId: number): ChannelDownloadSettings | undefined; - getRssChannels(): RssChannel[]; - updateChannelYoutubeId(channelId: number, youtubeChannelId: string): void; - updateLastCrawled(channelId: number): void; - setInterval(handler: () => void, timeout: number): unknown; - clearInterval(id: unknown): void; - sleep(ms: number): Promise; - log(...args: unknown[]): void; - error(...args: unknown[]): void; - fatal(error: unknown): void; -} - -export interface WorkerRuntime { - start(): void; - stop(signal: string): Promise; - runNextJob(): Promise; - startRssPoll(): Promise | undefined; -} - const productionDependencies: WorkerRuntimeDependencies = { mediaDir: config.MEDIA_DIR, crawlInitial: config.CRAWL_INITIAL, @@ -160,105 +88,13 @@ export function createWorkerRuntime( let activePoll: Promise | undefined; const intervalIds: unknown[] = []; - function enqueueThumbsFor(youtubeIds: string[]): void { - for (const youtubeId of youtubeIds) { - dependencies.enqueue('download_thumbnail', { youtubeId }); - } - } - - function enqueueAutoDownloadsFor(youtubeIds: string[], settings: ChannelDownloadSettings): void { - for (const youtubeId of youtubeIds) { - if (settings.autoDownloadVideo) { - dependencies.setVideoStatus(youtubeId, 'queued'); - dependencies.enqueue('download_video', { youtubeId }); - } - if (settings.autoDownloadAudio) { - dependencies.setAudioStatus(youtubeId, 'queued'); - dependencies.enqueue('download_audio', { youtubeId }); - } - } - } - - async function refreshChaptersFor(youtubeId: string): Promise { - try { - dependencies.setVideoChapters(youtubeId, await dependencies.fetchChapters(youtubeId)); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - dependencies.error(`chapter fetch failed for ${youtubeId}:`, msg); - } - } - - async function processJob(type: string, payload: string): Promise { - const data = JSON.parse(payload); - - switch (type) { - case 'download_video': { - dependencies.setVideoStatus(data.youtubeId, 'downloading'); - try { - const duration = await dependencies.downloadVideo( - data.youtubeId, - `${dependencies.mediaDir}/videos`, - ); - dependencies.setVideoStatus(data.youtubeId, 'ready'); - if (duration > 0) dependencies.setDurationIfZero(data.youtubeId, duration); - await refreshChaptersFor(data.youtubeId); - } catch (err) { - dependencies.setVideoStatus(data.youtubeId, 'none'); - throw err; - } - break; - } - - case 'download_audio': { - dependencies.setAudioStatus(data.youtubeId, 'downloading'); - try { - const duration = await dependencies.downloadAudio( - data.youtubeId, - `${dependencies.mediaDir}/audio`, - ); - dependencies.setAudioStatus(data.youtubeId, 'ready'); - if (duration > 0) dependencies.setDurationIfZero(data.youtubeId, duration); - await refreshChaptersFor(data.youtubeId); - } catch (err) { - dependencies.setAudioStatus(data.youtubeId, 'none'); - throw err; - } - break; - } - - case 'crawl_channel': { - const result = await dependencies.crawlChannel(data.url, 1, dependencies.crawlInitial); - const insertedYoutubeIds = dependencies.insertVideos( - result.entries, - data.channelId, - 'channel', - ); - enqueueThumbsFor(insertedYoutubeIds); - const channel = dependencies.getChannelById(data.channelId); - if (channel) enqueueAutoDownloadsFor(insertedYoutubeIds, channel); - if (result.channelYoutubeId) { - dependencies.updateChannelYoutubeId(data.channelId, result.channelYoutubeId); - } - break; - } - - case 'download_thumbnail': { - const destDir = `${dependencies.mediaDir}/thumbs`; - if (!dependencies.existsSync(`${destDir}/${data.youtubeId}.jpg`)) { - await dependencies.downloadThumb(data.youtubeId, destDir); - } - break; - } - } - } - async function runNextJob(): Promise { if (stopping) return false; const job = dependencies.take(); if (!job) return false; try { - await processJob(job.type, job.payload); + await processWorkerJob(dependencies, job.type, job.payload); dependencies.complete(job.id); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -285,30 +121,10 @@ export function createWorkerRuntime( }); } - async function pollRss(): Promise { - const channels = dependencies.getRssChannels(); - for (let index = 0; index < channels.length; index++) { - if (stopping) break; - const channel = channels[index]; - try { - const entries = await dependencies.fetchFeed(channel.youtubeChannelId); - const insertedYoutubeIds = dependencies.insertVideos(entries, channel.id, 'channel'); - enqueueThumbsFor(insertedYoutubeIds); - enqueueAutoDownloadsFor(insertedYoutubeIds, channel); - dependencies.updateLastCrawled(channel.id); - } catch (err) { - dependencies.error(`RSS poll failed for channel ${channel.id}:`, err); - } - if (!stopping && index < channels.length - 1) { - await dependencies.sleep(RSS_CHANNEL_DELAY_MS); - } - } - } - function startRssPoll(): Promise | undefined { if (stopping) return undefined; if (activePoll) return activePoll; - activePoll = pollRss() + activePoll = pollRss(dependencies, () => stopping) .catch((err) => { dependencies.error(err); })