diff --git a/docs/deployment/configuration.md b/docs/deployment/configuration.md index 076674d9..686ef0df 100644 --- a/docs/deployment/configuration.md +++ b/docs/deployment/configuration.md @@ -80,6 +80,7 @@ Push notifications won't work without these. The app will still function, but us | `SMS_DEV_MODE` | `false` | Set to `true` to bypass Twilio and auto-approve any verification code (dev/testing only) | | `GIPHY_API_KEY` | — | Giphy API key for GIF search in comments. Get one at [developers.giphy.com](https://developers.giphy.com/) | | `DATA_DIR` | `./data` | Directory for database and media files | +| `YTDLP_COOKIES_FILE` | — | Path to a Netscape-format cookies file passed to yt-dlp as `--cookies`. Supplies a logged-in session so login-gated / audience-restricted Instagram (and similar) posts download. Use a **secondary** account — bulk downloads can get an account rate-limited or flagged. Treat the file as a secret (mount read-only, keep out of git). | | `LOG_LEVEL` | `info` | Logging level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) | | `BACKUP_RETENTION_COUNT` | `7` | Number of daily database backups to keep | | `RATE_LIMITING` | `false` | Set to `true` to enable API rate limiting. Recommended for public-facing deployments. | diff --git a/src/lib/server/__tests__/download-notify.test.ts b/src/lib/server/__tests__/download-notify.test.ts new file mode 100644 index 00000000..b57dc276 --- /dev/null +++ b/src/lib/server/__tests__/download-notify.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { v4 as uuid } from 'uuid'; +import { eq } from 'drizzle-orm'; +import * as schema from '../db/schema'; + +vi.mock('$lib/server/db', async () => { + const { createTestDb } = await import('../../../../tests/helpers/db'); + return createTestDb(); +}); + +// Spy on the group-notify call so we can assert it fires ONLY on a genuine 'ready'. +vi.mock('$lib/server/push', () => ({ + notifyNewClip: vi.fn(async () => {}), + sendNotification: vi.fn(async () => {}) +})); + +// Provider is injected per-test. +const getActiveProvider = vi.fn(); +vi.mock('$lib/server/providers/registry', () => ({ + getActiveProvider: (...args: unknown[]) => getActiveProvider(...args) +})); + +// Pass-through the dedup wrapper so the inner download runs synchronously in-test. +vi.mock('$lib/server/download-lock', () => ({ + deduplicatedDownload: ( + clipId: string, + url: string, + fn: (clipId: string, url: string) => Promise + ) => fn(clipId, url) +})); + +const { db } = await import('$lib/server/db'); +const push = await import('$lib/server/push'); +const { downloadVideo, isNotAVideoError, NOT_A_VIDEO_TITLE } = await import('../video/download'); + +function setup() { + const groupId = uuid(); + const userId = uuid(); + const now = new Date(); + (db as any) + .insert(schema.groups) + .values({ + id: groupId, + name: 'G', + inviteCode: `i-${groupId.slice(0, 6)}`, + accentColor: 'coral', + reciprocitySeed: 5, + reciprocityCap: 5, + reciprocityRatio: 1, + queueLimit: 10, + downloadProvider: 'ytdlp', + createdAt: now + }) + .run(); + (db as any) + .insert(schema.users) + .values({ + id: userId, + username: `u-${userId.slice(0, 6)}`, + phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, + groupId, + postCredits: 5, + createdAt: now + }) + .run(); + return { groupId, userId }; +} + +function insertDownloadingClip(groupId: string, userId: string) { + const id = uuid(); + (db as any) + .insert(schema.clips) + .values({ + id, + groupId, + addedBy: userId, + originalUrl: `https://x/${id}`, + platform: 'instagram', + status: 'downloading', + contentType: 'video', + createdAt: new Date() + }) + .run(); + return id; +} + +function statusOf(clipId: string): string { + const row = (db as any) + .select({ status: schema.clips.status }) + .from(schema.clips) + .where(eq(schema.clips.id, clipId)) + .get(); + return row.status; +} + +beforeEach(() => { + (push.notifyNewClip as any).mockClear(); + getActiveProvider.mockReset(); +}); + +describe('download notifications only fire on a genuine ready post', () => { + it('does NOT notify when the download fails', async () => { + const { groupId, userId } = setup(); + const clipId = insertDownloadingClip(groupId, userId); + getActiveProvider.mockResolvedValue({ + downloadVideo: vi.fn(async () => { + throw new Error('yt-dlp exited with code 1: empty media response'); + }) + }); + + await downloadVideo(clipId, 'https://x/fail'); + + expect(statusOf(clipId)).toBe('failed'); + expect(push.notifyNewClip).not.toHaveBeenCalled(); + }); + + it('does NOT notify when no provider is configured', async () => { + const { groupId, userId } = setup(); + const clipId = insertDownloadingClip(groupId, userId); + getActiveProvider.mockResolvedValue(null); + + await downloadVideo(clipId, 'https://x/noprovider'); + + expect(statusOf(clipId)).toBe('failed'); + expect(push.notifyNewClip).not.toHaveBeenCalled(); + }); + + it('DOES notify once when the download succeeds and the clip goes ready', async () => { + const { groupId, userId } = setup(); + const clipId = insertDownloadingClip(groupId, userId); + getActiveProvider.mockResolvedValue({ + downloadVideo: vi.fn(async () => ({ + videoPath: '/nonexistent/video.mp4', + thumbnailPath: null, + title: 'a caption', + duration: 12, + creatorName: 'someone', + creatorUrl: null, + sourceViewCount: 42 + })) + }); + + await downloadVideo(clipId, 'https://x/ok'); + + expect(statusOf(clipId)).toBe('ready'); + expect(push.notifyNewClip).toHaveBeenCalledTimes(1); + expect(push.notifyNewClip).toHaveBeenCalledWith(clipId); + }); +}); + +describe('confirmed photo shares are rejected as not-a-video', () => { + it('isNotAVideoError matches the yt-dlp no-video signatures only', () => { + expect(isNotAVideoError('ERROR: [Instagram] X: There is no video in this post')).toBe(true); + expect(isNotAVideoError('ERROR: [Instagram] X: No video formats found!')).toBe(true); + expect(isNotAVideoError('ERROR: [Instagram] X: Instagram sent an empty media response')).toBe( + false + ); + expect(isNotAVideoError('ERROR: unable to download webpage: network is unreachable')).toBe( + false + ); + }); + + it('fails a photo share with a clear reason and does NOT notify', async () => { + const { groupId, userId } = setup(); + const clipId = insertDownloadingClip(groupId, userId); + getActiveProvider.mockResolvedValue({ + downloadVideo: vi.fn(async () => { + throw new Error( + 'yt-dlp exited with code 1: ERROR: [Instagram] X: There is no video in this post' + ); + }) + }); + + await downloadVideo(clipId, 'https://instagram.com/p/photo'); + + const row = (db as any) + .select({ status: schema.clips.status, title: schema.clips.title }) + .from(schema.clips) + .where(eq(schema.clips.id, clipId)) + .get(); + expect(row.status).toBe('failed'); + expect(row.title).toBe(NOT_A_VIDEO_TITLE); + expect(push.notifyNewClip).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/providers/__tests__/ytdlp.test.ts b/src/lib/server/providers/__tests__/ytdlp.test.ts new file mode 100644 index 00000000..b083dd51 --- /dev/null +++ b/src/lib/server/providers/__tests__/ytdlp.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { + parseYtdlpDate, + ytdlpAgeDays, + isYtdlpStale, + YTDLP_STALE_DAYS, + extractCaption +} from '../ytdlp/version'; + +describe('yt-dlp version parsing', () => { + it('parses a date-stamped version to a UTC date', () => { + const d = parseYtdlpDate('2026.07.04'); + expect(d?.toISOString()).toBe('2026-07-04T00:00:00.000Z'); + }); + + it('parses versions with a build suffix', () => { + const d = parseYtdlpDate('2026.07.04.123456'); + expect(d?.toISOString()).toBe('2026-07-04T00:00:00.000Z'); + }); + + it('returns null for null / unparseable / dev-hash versions', () => { + expect(parseYtdlpDate(null)).toBeNull(); + expect(parseYtdlpDate('')).toBeNull(); + expect(parseYtdlpDate('deadbeef')).toBeNull(); + expect(parseYtdlpDate('nightly')).toBeNull(); + }); +}); + +describe('ytdlpAgeDays', () => { + const now = Date.UTC(2026, 6, 4); // 2026-07-04 + + it('computes whole-day age from a version date', () => { + expect(ytdlpAgeDays('2026.07.04', now)).toBe(0); + expect(ytdlpAgeDays('2026.06.04', now)).toBe(30); + expect(ytdlpAgeDays('2026.02.21', now)).toBe(133); + }); + + it('returns null when unparseable', () => { + expect(ytdlpAgeDays(null, now)).toBeNull(); + expect(ytdlpAgeDays('nightly', now)).toBeNull(); + }); +}); + +describe('isYtdlpStale', () => { + const now = Date.UTC(2026, 6, 4); + + it('is false for a fresh build and true past the threshold', () => { + expect(isYtdlpStale('2026.07.04', now)).toBe(false); + expect(isYtdlpStale('2026.06.04', now)).toBe(false); // 30 days, under 90 + expect(isYtdlpStale('2026.02.21', now)).toBe(true); // 133 days + }); + + it('is exactly boundary-safe at the threshold', () => { + // A build exactly YTDLP_STALE_DAYS old is not yet "older than" the threshold. + const boundary = new Date(now - YTDLP_STALE_DAYS * 86_400_000); + const v = `${boundary.getUTCFullYear()}.${String(boundary.getUTCMonth() + 1).padStart(2, '0')}.${String(boundary.getUTCDate()).padStart(2, '0')}`; + expect(isYtdlpStale(v, now)).toBe(false); + }); + + it('never flags an unparseable version as stale', () => { + expect(isYtdlpStale(null, now)).toBe(false); + expect(isYtdlpStale('nightly', now)).toBe(false); + }); +}); + +describe('extractCaption', () => { + it('prefers description over title', () => { + expect(extractCaption({ description: 'real caption', title: 'Video by @x' })).toBe( + 'real caption' + ); + }); + + it('falls back to title then fulltitle', () => { + expect(extractCaption({ title: 'the title' })).toBe('the title'); + expect(extractCaption({ fulltitle: 'the fulltitle' })).toBe('the fulltitle'); + }); + + it('truncates very long captions with an ellipsis', () => { + const long = 'x'.repeat(600); + const out = extractCaption({ description: long }); + expect(out?.length).toBe(501); // 500 chars + ellipsis + expect(out?.endsWith('…')).toBe(true); + }); + + it('returns null when there is nothing usable', () => { + expect(extractCaption({})).toBeNull(); + }); +}); diff --git a/src/lib/server/providers/registry.ts b/src/lib/server/providers/registry.ts index c017af25..c7ece1cd 100644 --- a/src/lib/server/providers/registry.ts +++ b/src/lib/server/providers/registry.ts @@ -3,6 +3,7 @@ import { groups } from '../db/schema'; import { eq } from 'drizzle-orm'; import type { KnownProvider, DownloadProvider } from './types'; import { YtDlpProvider } from './ytdlp'; +import { isYtdlpStale } from './ytdlp/version'; import { FakeProvider } from './fake'; const TEST_MODE = process.env.TEST_MODE === 'true'; @@ -15,7 +16,10 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [ homepage: 'https://github.com/yt-dlp/yt-dlp', license: 'Unlicense', capabilities: ['video', 'music'], - binaryUrl: 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp', + // Nightly channel: extractor fixes for IG/TikTok/YT land here days-to-weeks before + // stable. The scheduler self-updates this binary, so tracking nightly keeps the app + // resilient to the frequent site changes that break social-media downloads. + binaryUrl: 'https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp', dependencies: ['ffmpeg', 'python3'] }, ...(TEST_MODE @@ -72,6 +76,8 @@ export interface ProviderStatus { installed: boolean; active: boolean; version: string | null; + /** yt-dlp only: true when the installed build is older than the staleness threshold. */ + stale: boolean; } export async function listProvidersWithStatus(groupId: string): Promise { @@ -87,7 +93,8 @@ export async function listProvidersWithStatus(groupId: string): Promise): string | null { - const desc = typeof info.description === 'string' ? info.description.trim() : ''; - const title = typeof info.title === 'string' ? info.title.trim() : ''; - const fulltitle = typeof info.fulltitle === 'string' ? (info.fulltitle as string).trim() : ''; - const caption = desc || title || fulltitle || null; - if (!caption) return null; - return caption.length > MAX_CAPTION_LENGTH - ? caption.slice(0, MAX_CAPTION_LENGTH).trimEnd() + '…' - : caption; -} +const COOKIES_FILE = process.env.YTDLP_COOKIES_FILE?.trim() || null; export class YtDlpProvider implements DownloadProvider { readonly id = PROVIDER_ID; @@ -82,22 +72,52 @@ export class YtDlpProvider implements DownloadProvider { } async downloadVideo(url: string, options: DownloadOptions): Promise { - return this.runVideoDownload(url, options); + return this.runVideoDownloadWithRetries(url, options); } async downloadAudio(searchQuery: string, options: DownloadOptions): Promise { return this.runAudioDownloadWithRetries(searchQuery, options); } - private runVideoDownload(url: string, options: DownloadOptions): Promise { + private async runVideoDownloadWithRetries( + url: string, + options: DownloadOptions + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + return await this.runVideoDownloadOnce(url, options); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < MAX_RETRIES) { + const delay = RETRY_DELAY_MS * attempt; + log.warn( + `yt-dlp video attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delay}ms...` + ); + await new Promise((r) => setTimeout(r, delay)); + } + } + } + + throw lastError ?? new Error('yt-dlp video download failed'); + } + + private runVideoDownloadOnce( + url: string, + options: DownloadOptions + ): Promise { return new Promise((resolvePromise, reject) => { const outputTemplate = `${options.outputDir}/${options.clipId}.%(ext)s`; const binary = this.getBinaryCommand(); const args = [ '--no-playlist', + // Prefer the best sub-1080p video+audio and merge; fall back to a muxed format, then + // anything. Sites like Instagram now serve split DASH streams that a bare `best` + // (muxed-only) selector can miss, so select streams explicitly and let FFmpeg merge. '-f', - 'best[height<=1080]/best', + 'bv*[height<=1080]+ba/b[height<=1080]/b', '--write-thumbnail', '--convert-thumbnails', 'jpg', @@ -105,6 +125,7 @@ export class YtDlpProvider implements DownloadProvider { '--js-runtimes', 'node' ]; + if (COOKIES_FILE) args.push('--cookies', COOKIES_FILE); if (options.maxFileSizeBytes) { args.push('--max-filesize', String(options.maxFileSizeBytes)); @@ -240,6 +261,7 @@ export class YtDlpProvider implements DownloadProvider { outputTemplate, url ]; + if (COOKIES_FILE) args.push('--cookies', COOKIES_FILE); await new Promise((resolve, reject) => { const proc = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] }); @@ -302,6 +324,7 @@ export class YtDlpProvider implements DownloadProvider { '0', '--write-info-json' ]; + if (COOKIES_FILE) args.push('--cookies', COOKIES_FILE); if (options.maxFileSizeBytes) { args.push('--max-filesize', String(options.maxFileSizeBytes)); diff --git a/src/lib/server/providers/ytdlp/version.ts b/src/lib/server/providers/ytdlp/version.ts new file mode 100644 index 00000000..a2823ef3 --- /dev/null +++ b/src/lib/server/providers/ytdlp/version.ts @@ -0,0 +1,52 @@ +/** + * Pure, dependency-free yt-dlp helpers (version parsing + caption extraction). + * + * Kept in a leaf module (no imports) so both the provider and its consumers — the registry, + * scheduler, and health route — can use them without pulling in the provider/registry import + * cycle. Also makes them trivially unit-testable in isolation. + */ + +const MAX_CAPTION_LENGTH = 500; + +/** yt-dlp warns once a build is older than this; mirror the threshold for our own staleness UI. */ +export const YTDLP_STALE_DAYS = 90; + +/** + * Extract the best caption/description from yt-dlp info.json metadata. + * Prefers `description` (actual post caption on TikTok/IG/etc.) over `title` + * (which is often generic like "Video by @username"). + */ +export function extractCaption(info: Record): string | null { + const desc = typeof info.description === 'string' ? info.description.trim() : ''; + const title = typeof info.title === 'string' ? info.title.trim() : ''; + const fulltitle = typeof info.fulltitle === 'string' ? (info.fulltitle as string).trim() : ''; + const caption = desc || title || fulltitle || null; + if (!caption) return null; + return caption.length > MAX_CAPTION_LENGTH + ? caption.slice(0, MAX_CAPTION_LENGTH).trimEnd() + '…' + : caption; +} + +/** + * yt-dlp releases are date-stamped `YYYY.MM.DD(.NNN)`. Parse the date portion to a UTC Date, + * or null when the string doesn't match (e.g. a git-hash dev build). + */ +export function parseYtdlpDate(version: string | null): Date | null { + if (!version) return null; + const m = version.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})/); + if (!m) return null; + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** Whole-day age of a yt-dlp version string, or null if unparseable. */ +export function ytdlpAgeDays(version: string | null, now: number = Date.now()): number | null { + const d = parseYtdlpDate(version); + return d ? Math.floor((now - d.getTime()) / 86_400_000) : null; +} + +/** True when a yt-dlp version is parseable and older than YTDLP_STALE_DAYS. */ +export function isYtdlpStale(version: string | null, now: number = Date.now()): boolean { + const age = ytdlpAgeDays(version, now); + return age !== null && age > YTDLP_STALE_DAYS; +} diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index 0236d346..4d965554 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -11,6 +11,8 @@ import { sendNotification, rollupCreditEarnings } from '$lib/server/push'; import { runBackup } from '$lib/server/backup'; import { publishMusicClip } from '$lib/server/music/publish'; import { deleteWaveform } from '$lib/server/audio/waveform'; +import { getProviderInstance } from '$lib/server/providers/registry'; +import { ytdlpAgeDays } from '$lib/server/providers/ytdlp/version'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; @@ -22,6 +24,7 @@ let lastReminderDate: string | null = null; const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour const TRIM_CHECK_INTERVAL = 10 * 1000; // 10 seconds const CREDIT_ROLLUP_INTERVAL = 30 * 60 * 1000; // 30 minutes +const YTDLP_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // weekly const REMINDER_HOUR = 9; // 9 AM server time const BACKUP_HOUR = 2; // 2 AM server time const REMINDER_BODIES = [ @@ -40,15 +43,50 @@ export function startScheduler(): void { checkAndSendReminders(); checkAndRunBackup(); checkAndAutoPublish(); + // Keep the yt-dlp binary current: refresh on boot if stale, then weekly. Extraction for IG/ + // TikTok/YT breaks routinely as those sites change, and the provider binary lives in a + // persistent volume, so without this it freezes at whatever version was first installed. + checkAndUpdateYtdlp(); setInterval(checkAndSendReminders, CHECK_INTERVAL); setInterval(checkAndRunBackup, CHECK_INTERVAL); setInterval(checkAndAutoPublish, TRIM_CHECK_INTERVAL); + setInterval(() => checkAndUpdateYtdlp(true), YTDLP_UPDATE_INTERVAL); // Roll up earned credits into one push per user every 30 min (not on startup — let the first // window accumulate rather than flushing a backlog right after a restart). setInterval(checkAndRollupCredits, CREDIT_ROLLUP_INTERVAL); log.info('scheduler started'); } +/** + * Refresh the yt-dlp provider binary from its configured release channel (see registry.ts). + * On boot (`force=false`) it only re-downloads when the current build is missing a parseable + * date or is at least a day old, so frequent restarts don't re-pull needlessly; the weekly + * tick passes `force=true` to refresh unconditionally. Best-effort — network failures are + * logged and swallowed so a GitHub outage never takes the app down. + */ +async function checkAndUpdateYtdlp(force = false): Promise { + try { + const provider = getProviderInstance('ytdlp'); + if (!provider || !(await provider.isInstalled())) return; + + const before = await provider.getVersion(); + if (!force) { + const age = ytdlpAgeDays(before); + if (age !== null && age < 1) { + log.debug({ version: before }, 'yt-dlp current, skipping boot refresh'); + return; + } + } + + await provider.install(); // re-download newest build (overwrites the binary in place) + const after = await provider.getVersion(); + if (before !== after) log.info({ before, after }, 'yt-dlp updated'); + else log.info({ version: after }, 'yt-dlp refresh complete (already latest)'); + } catch (err) { + log.error({ err }, 'yt-dlp update failed'); + } +} + async function checkAndSendReminders(): Promise { const now = new Date(); const today = now.toISOString().split('T')[0]; diff --git a/src/lib/server/video/download.ts b/src/lib/server/video/download.ts index 45caeeed..dae7225c 100644 --- a/src/lib/server/video/download.ts +++ b/src/lib/server/video/download.ts @@ -9,12 +9,23 @@ import { cleanupClipFiles, totalFileSize } from '$lib/server/download-utils'; -import { notifyNewClip } from '$lib/server/push'; import { setClipReady } from '$lib/server/queue'; import { createLogger } from '$lib/server/logger'; const log = createLogger('video'); +/** + * yt-dlp signatures for a URL that resolved to a post with no video (an Instagram photo or + * image carousel, etc.). Scrolly is video-only, so these shares are rejected with a clear + * reason instead of being left as an opaque failure. + */ +export function isNotAVideoError(message: string): boolean { + return /no video (in this post|formats found|could be found)/i.test(message); +} + +/** User-facing reason stamped on a clip whose source had no video. */ +export const NOT_A_VIDEO_TITLE = 'Not a video — Scrolly only supports video posts'; + async function handleDownloadError( clipId: string, err: unknown, @@ -32,20 +43,27 @@ async function handleDownloadError( .update(clips) .set({ status: 'failed', title: `Exceeds ${sizeMb} MB limit` }) .where(eq(clips.id, clipId)); - // Still notify — clip is viewable via external link - await notifyNewClip(clipId).catch((err2) => - log.error({ err: err2, clipId }, 'push notification failed') - ); + // No notification: the clip never uploaded, so there is nothing viewable to announce. + return; + } + + // Reject confirmed non-video posts (photos/carousels) with a clear, user-facing reason. + if (isNotAVideoError(errMsg)) { + log.info({ clipId }, 'share rejected: not a video (photo post)'); + await cleanupClipFiles(clipId); + await db + .update(clips) + .set({ status: 'failed', title: NOT_A_VIDEO_TITLE }) + .where(eq(clips.id, clipId)); + // No notification: rejected shares are never announced. return; } log.error({ err, clipId }, 'download failed'); await cleanupClipFiles(clipId); await db.update(clips).set({ status: 'failed' }).where(eq(clips.id, clipId)); - // Still notify — clip is viewable via external link - await notifyNewClip(clipId).catch((err2) => - log.error({ err: err2, clipId }, 'push notification failed') - ); + // No notification: a failed download is not a real post. The group is only ever + // notified once a clip reaches 'ready' (see setClipReady / publishQueuedClip). } async function downloadVideoInner(clipId: string, url: string): Promise { @@ -63,10 +81,7 @@ async function downloadVideoInner(clipId: string, url: string): Promise { .update(clips) .set({ status: 'failed', title: 'No download provider configured' }) .where(eq(clips.id, clipId)); - // Still notify — clip is viewable via external link - await notifyNewClip(clipId).catch((err) => - log.error({ err, clipId }, 'push notification failed') - ); + // No notification: nothing was uploaded, so there is nothing viewable to announce. return; } @@ -99,10 +114,7 @@ async function downloadVideoInner(clipId: string, url: string): Promise { sourceViewCount: result.sourceViewCount }) .where(eq(clips.id, clipId)); - // Still notify — clip is viewable via external link - await notifyNewClip(clipId).catch((err) => - log.error({ err, clipId }, 'push notification failed') - ); + // No notification: the clip was rejected for size and never became viewable. return; } diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index 003032d9..20d7511c 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,15 +1,37 @@ import type { RequestHandler } from './$types'; +import { getProviderInstance } from '$lib/server/providers/registry'; +import { ytdlpAgeDays, isYtdlpStale } from '$lib/server/providers/ytdlp/version'; const startedAt = Date.now(); +// /health is polled frequently by the Docker healthcheck, and getVersion() spawns the binary, +// so cache the yt-dlp version briefly instead of shelling out on every hit. +const VERSION_TTL_MS = 60_000; +let cachedVersion: { value: string | null; at: number } | null = null; + +async function ytdlpVersion(now: number): Promise { + if (cachedVersion && now - cachedVersion.at < VERSION_TTL_MS) return cachedVersion.value; + const provider = getProviderInstance('ytdlp'); + const value = provider ? await provider.getVersion() : null; + cachedVersion = { value, at: now }; + return value; +} + export const GET: RequestHandler = async () => { - const uptime = Math.floor((Date.now() - startedAt) / 1000); + const now = Date.now(); + const uptime = Math.floor((now - startedAt) / 1000); + const ytdlp = await ytdlpVersion(now); return new Response( JSON.stringify({ status: 'ok', version: __APP_VERSION__, - uptime + uptime, + ytdlp: { + version: ytdlp, + ageDays: ytdlpAgeDays(ytdlp, now), + stale: isYtdlpStale(ytdlp, now) + } }), { headers: { 'Content-Type': 'application/json' } } );