Refetch missing media from catalog source
This commit is contained in:
@@ -158,7 +158,7 @@ function metaFromInfo(info, duration) {
|
|||||||
// getInfo async (videoId) => yt-dlp -J info — duration, live flags, meta
|
// getInfo async (videoId) => yt-dlp -J info — duration, live flags, meta
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
export function createMediaCache({
|
export function createMediaCache({
|
||||||
dir, db, download, getInfo,
|
dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`,
|
||||||
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
|
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
|
||||||
maxBytes = 10 * 1024 ** 3,
|
maxBytes = 10 * 1024 ** 3,
|
||||||
minFreeBytes = 5 * 1024 ** 3,
|
minFreeBytes = 5 * 1024 ** 3,
|
||||||
@@ -314,7 +314,9 @@ export function createMediaCache({
|
|||||||
await job.persisted;
|
await job.persisted;
|
||||||
try {
|
try {
|
||||||
await setStatus(job, 'downloading');
|
await setStatus(job, 'downloading');
|
||||||
const info = await getInfo(id);
|
const sourceUrl = await getSourceUrl(id);
|
||||||
|
if (!sourceUrl || !/^https:\/\/www\.youtube\.com\/watch\?v=[\w-]{11}$/.test(sourceUrl)) throw new MediaSkip('source URL is unavailable');
|
||||||
|
const info = await getInfo(id, sourceUrl);
|
||||||
const live = info.is_live || ['is_live', 'post_live', 'is_upcoming'].includes(info.live_status);
|
const live = info.is_live || ['is_live', 'post_live', 'is_upcoming'].includes(info.live_status);
|
||||||
if (live) throw new MediaSkip('live streams are not cached');
|
if (live) throw new MediaSkip('live streams are not cached');
|
||||||
const expected = typeof info.duration === 'number' ? info.duration : 0;
|
const expected = typeof info.duration === 'number' ? info.duration : 0;
|
||||||
@@ -324,7 +326,7 @@ export function createMediaCache({
|
|||||||
}
|
}
|
||||||
await makeRoom(id, Math.max(expected, 60) * EST_BYTES_PER_SEC);
|
await makeRoom(id, Math.max(expected, 60) * EST_BYTES_PER_SEC);
|
||||||
|
|
||||||
const raw = await download(id, `${base}.dl.mp4`);
|
const raw = await download(id, `${base}.dl.mp4`, sourceUrl);
|
||||||
await setStatus(job, 'validating');
|
await setStatus(job, 'validating');
|
||||||
|
|
||||||
// Normalise: first video + first audio, moov atom up front so the copy
|
// Normalise: first video + first audio, moov atom up front so the copy
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
|
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ let dirN = 0;
|
|||||||
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
||||||
const dir = join(root, 'media' + dirN++);
|
const dir = join(root, 'media' + dirN++);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const sources = [];
|
||||||
const db = {
|
const db = {
|
||||||
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
|
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
|
||||||
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
|
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
|
||||||
@@ -102,13 +103,13 @@ function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
|
|||||||
const cache = createMediaCache({
|
const cache = createMediaCache({
|
||||||
dir, db, log: silentLog,
|
dir, db, log: silentLog,
|
||||||
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
|
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
|
||||||
download: async (id, out) => { calls.push(id); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
|
download: async (id, out, sourceUrl) => { calls.push(id); sources.push({ id, sourceUrl }); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
|
||||||
transcode: { enabled: false },
|
transcode: { enabled: false },
|
||||||
freeBytes: () => 100 * 1024 ** 3,
|
freeBytes: () => 100 * 1024 ** 3,
|
||||||
backfillDelayMs: -1,
|
backfillDelayMs: -1,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
return { cache, dir, calls, state };
|
return { cache, dir, calls, sources, state };
|
||||||
}
|
}
|
||||||
async function clearDb() {
|
async function clearDb() {
|
||||||
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
|
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
|
||||||
@@ -233,6 +234,19 @@ describe('media cache jobs', () => {
|
|||||||
expect(calls.length).toBe(2);
|
expect(calls.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('missing cached files are re-fetched from the stored source URL', async () => {
|
||||||
|
await clearDb();
|
||||||
|
const id = 'srcAAAAAA01';
|
||||||
|
const sourceUrl = `https://www.youtube.com/watch?v=${id}`;
|
||||||
|
const { cache, dir, calls, sources } = makeCache({ getSourceUrl: async () => sourceUrl });
|
||||||
|
await cache.init();
|
||||||
|
const first = await cache.ensureCached(id, { priority: HIGH });
|
||||||
|
unlinkSync(join(dir, `${id}.${first.gen}.mp4`));
|
||||||
|
await cache.ensureCached(id, { priority: HIGH });
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(sources.every(call => call.id === id && call.sourceUrl === sourceUrl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
|
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
|
||||||
await clearDb();
|
await clearDb();
|
||||||
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
|
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now()
|
|||||||
export function registerNoteRoutes(app, deps) {
|
export function registerNoteRoutes(app, deps) {
|
||||||
const {
|
const {
|
||||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
||||||
|
catalog = null, media = null,
|
||||||
} = deps;
|
} = deps;
|
||||||
const ADMIN_COOKIE = 'ytp_admin';
|
const ADMIN_COOKIE = 'ytp_admin';
|
||||||
const ADMIN_TTL = 30 * 24 * 3600;
|
const ADMIN_TTL = 30 * 24 * 3600;
|
||||||
@@ -695,6 +696,7 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
const n = await db.getNotes(r.video_id);
|
const n = await db.getNotes(r.video_id);
|
||||||
out.push({
|
out.push({
|
||||||
id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
|
id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
|
||||||
|
sourceUrl: catalog ? await catalog.getSourceUrl(r.video_id) : null,
|
||||||
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
|
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
|
||||||
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
|
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
|
||||||
});
|
});
|
||||||
@@ -703,6 +705,12 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
return c.json({ ok: true, media: out });
|
return c.json({ ok: true, media: out });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => {
|
||||||
|
if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
|
||||||
|
try { return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) }, 202); }
|
||||||
|
catch (err) { return c.json({ ok: false, error: err.message }, 400); }
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
||||||
|
|
||||||
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ beforeAll(async () => {
|
|||||||
backupDir: join(root, 'backups'),
|
backupDir: join(root, 'backups'),
|
||||||
adminHtmlPath: htmlPath,
|
adminHtmlPath: htmlPath,
|
||||||
workerToken: 'w'.repeat(32),
|
workerToken: 'w'.repeat(32),
|
||||||
|
catalog: { getSourceUrl: async id => /^[\w-]{11}$/.test(id) ? `https://www.youtube.com/watch?v=${id}` : null },
|
||||||
|
media: { redownload: async id => ({ status: `queued:${id}` }) },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,5 +280,7 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy
|
|||||||
const cookie = await adminCookie();
|
const cookie = await adminCookie();
|
||||||
const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } });
|
const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } });
|
||||||
const song = (await res.json()).media.find(x => x.id === id);
|
const song = (await res.json()).media.find(x => x.id === id);
|
||||||
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed' });
|
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed', sourceUrl: 'https://www.youtube.com/watch?v=phoneSong01' });
|
||||||
|
expect((await app.request(`/api/admin/media/${id}/redownload`, { method: 'POST', headers: { Cookie: cookie } })).status).toBe(202);
|
||||||
|
expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1104,7 +1104,7 @@ const SAVE_DEFAULT_FORMAT =
|
|||||||
// again) share ONE yt-dlp run instead of spawning another each time.
|
// again) share ONE yt-dlp run instead of spawning another each time.
|
||||||
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
|
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
|
||||||
|
|
||||||
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
|
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal, sourceUrl = null) {
|
||||||
await assertNotLive(videoId);
|
await assertNotLive(videoId);
|
||||||
const key = videoId + '|' + formatArgs.join(' ');
|
const key = videoId + '|' + formatArgs.join(' ');
|
||||||
let entry = inflightDownloads.get(key);
|
let entry = inflightDownloads.get(key);
|
||||||
@@ -1116,7 +1116,7 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
|
|||||||
entry.promise = withSaveSlot(() => {
|
entry.promise = withSaveSlot(() => {
|
||||||
if (ctl.signal.aborted) throw new Error('save cancelled');
|
if (ctl.signal.aborted) throw new Error('save cancelled');
|
||||||
return runYtdlpResilient([
|
return runYtdlpResilient([
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
sourceUrl || await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
'--no-warnings', '--no-playlist',
|
'--no-warnings', '--no-playlist',
|
||||||
...formatArgs,
|
...formatArgs,
|
||||||
'--limit-rate', DOWNLOAD_RATE,
|
'--limit-rate', DOWNLOAD_RATE,
|
||||||
@@ -1187,7 +1187,7 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal, cachedSrc
|
|||||||
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams
|
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams
|
||||||
// — unless the server cache already holds it.
|
// — unless the server cache already holds it.
|
||||||
if (!cachedSrc) await withSaveSlot(() => runYtdlpResilient([
|
if (!cachedSrc) await withSaveSlot(() => runYtdlpResilient([
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
'--no-warnings', '--no-playlist',
|
'--no-warnings', '--no-playlist',
|
||||||
'-f', SAVE_MUX_FORMAT,
|
'-f', SAVE_MUX_FORMAT,
|
||||||
'--merge-output-format', 'mp4',
|
'--merge-output-format', 'mp4',
|
||||||
@@ -1243,6 +1243,7 @@ if (!MEDIA_VOLUME_MARKER || existsSync(MEDIA_VOLUME_MARKER)) mkdirSync(MEDIA_DIR
|
|||||||
const media = createMediaCache({
|
const media = createMediaCache({
|
||||||
dir: MEDIA_DIR,
|
dir: MEDIA_DIR,
|
||||||
volumeMarker: MEDIA_VOLUME_MARKER,
|
volumeMarker: MEDIA_VOLUME_MARKER,
|
||||||
|
getSourceUrl: videoCatalog.getSourceUrl,
|
||||||
db: {
|
db: {
|
||||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
|
||||||
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
|
||||||
@@ -1251,9 +1252,9 @@ const media = createMediaCache({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
getInfo: async (videoId) => (await resolveStreams(videoId)).info,
|
getInfo: async (videoId) => (await resolveStreams(videoId)).info,
|
||||||
download: async (videoId, out) => {
|
download: async (videoId, out, sourceUrl) => {
|
||||||
await withSaveSlot(() => runYtdlpResilient([
|
await withSaveSlot(() => runYtdlpResilient([
|
||||||
`https://www.youtube.com/watch?v=${videoId}`,
|
sourceUrl || `https://www.youtube.com/watch?v=${videoId}`,
|
||||||
'--no-warnings', '--no-playlist',
|
'--no-warnings', '--no-playlist',
|
||||||
'-f', SAVE_MUX_FORMAT,
|
'-f', SAVE_MUX_FORMAT,
|
||||||
'--merge-output-format', 'mp4',
|
'--merge-output-format', 'mp4',
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const canonicalThumb = id => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
|
|||||||
const canonicalUrl = id => `https://www.youtube.com/watch?v=${id}`;
|
const canonicalUrl = id => `https://www.youtube.com/watch?v=${id}`;
|
||||||
export const foldSearchText = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
export const foldSearchText = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||||
export const channelKey = c => String(c.channelId || c.channel || '').toLowerCase().replace(/\s*-\s*topic$|vevo$|\s+official$/g, '').trim();
|
export const channelKey = c => String(c.channelId || c.channel || '').toLowerCase().replace(/\s*-\s*topic$|vevo$|\s+official$/g, '').trim();
|
||||||
|
export async function getSourceUrl(id) {
|
||||||
|
if (!VIDEO_ID.test(String(id || ''))) return null;
|
||||||
|
const row = (await db.execute({ sql: 'SELECT url FROM video_meta WHERE id=?', args: [id] })).rows[0];
|
||||||
|
return row?.url || `https://www.youtube.com/watch?v=${id}`;
|
||||||
|
}
|
||||||
function safeThumbnail(url) {
|
function safeThumbnail(url) {
|
||||||
try { const u = new URL(url); return u.protocol === 'https:' && /^(?:i|i\d)\.ytimg\.com$/.test(u.hostname) && !u.port && !u.username && !u.password; }
|
try { const u = new URL(url); return u.protocol === 'https:' && /^(?:i|i\d)\.ytimg\.com$/.test(u.hostname) && !u.port && !u.username && !u.password; }
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
|
|||||||
Reference in New Issue
Block a user