// 2025-11-07-import-thumbnails.js import PocketBase from 'pocketbase'; import { readFileSync } from 'node:fs'; import { basename, join } from 'node:path'; import spawn from 'nano-spawn'; import { tmpdir } from 'node:os'; import mime from 'mime'; const pb = new PocketBase(process.env.POCKETBASE_URL || 'http://127.0.0.1:8090'); if (!process.env.POCKETBASE_USERNAME) throw new Error('POCKETBASE_USERNAME missing'); if (!process.env.POCKETBASE_PASSWORD) throw new Error('POCKETBASE_PASSWORD missing'); if (!process.env.B2_BUCKET_FROM) throw new Error('B2_BUCKET_FROM missing'); if (!process.env.V1_DATA_FILE) throw new Error('V1_DATA_FILE missing'); interface ManifestItem { thumbnail_url: string; thumbnail_key: string; tmp_file: string; vod_id: string; } type Manifest = ManifestItem[]; interface Vod { id: string; thumbnail: string; streamDate: string; } const vodsCollectionName = 'pbc_144770472'; // pbc_144770472 is the vods collection // cv6m31vj98gmtsx is a sample vod id async function main() { console.log('Authenticating with PocketBase...'); await pb .collection("_superusers") .authWithPassword(process.env.POCKETBASE_USERNAME!, process.env.POCKETBASE_PASSWORD!); // Use vod.streamDate to find the correct entry // load v1 datafile const v1VodDataRaw = readFileSync(process.env.V1_DATA_FILE!, { encoding: 'utf-8' }); const v1VodData = JSON.parse(v1VodDataRaw).map((vod: any) => vod.data); console.log('v1VodData sample', v1VodData[0]) // # BUILD A MANIFEST let manifest: Manifest = []; const vods = await pb.collection('vods').getFullList(); for (const vod of vods) { // console.log('v2VodData sample', vod) // console.log(`for this vod`, vod) const v1Vod = v1VodData.find((vod1: Vod) => new Date(vod1.streamDate).getTime() === new Date(vod.streamDate).getTime()); console.log(`v1vod`, v1Vod); if (!v1Vod) { console.warn(`failed to find matching v1 data vod for vod ${vod.id} ${vod.streamDate}`); continue; } // skip if there is no thumbnail in the v1 vod if (!v1Vod.thumbnail) continue; // get a temporary file path to which we will DL const tmpFile = join(tmpdir(), basename(v1Vod.thumbnail)); // we will take the thumbnail url, download it, then upload it to the specified vod record. const entry: ManifestItem = { thumbnail_url: v1Vod.thumbnail, thumbnail_key: basename(v1Vod.thumbnail), tmp_file: tmpFile, vod_id: vod.id, }; manifest.push(entry); } // sanity check const invalidVods = manifest.filter((m) => m.thumbnail_url.includes('mp4')) if (invalidVods.length > 0) { console.warn('invalid thumbnails found', invalidVods) // throw new Error('invalid. mp4s found in thumbnails'); } const validManifest = manifest.filter((m) => !m.thumbnail_url.includes('mp4')); // console.log('manifest', manifest); // # ACTUAL WORK // Download the thumbnail to tmp file // upload the tmp file to pocketbase function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } async function retry(fn: any, retries = 6, delayMs = 500) { let lastError; for (let attempt = 1; attempt <= retries; attempt++) { try { return await fn(); } catch (err) { lastError = err; console.warn(`Attempt ${attempt} failed: ${err}. Retrying in ${delayMs}ms...`); if (attempt < retries) await sleep(delayMs); } } throw lastError; } for (const [i, m] of validManifest.entries()) { var from = "b2://" + process.env.B2_BUCKET_FROM + "/" + m.thumbnail_key; var to = m.tmp_file; var vodId = m.vod_id; console.log("processing thumbnail " + i + ". " + from + " -> " + to + " (vod_id=" + m.vod_id + ")"); // Retry the download await retry(function () { return spawn('b2', ['file', 'download', from, to]); }); // Retry the PocketBase upload await retry(async function () { var fileData = readFileSync(m.tmp_file); var mimetype = mime.getType(m.tmp_file) || undefined; var file = new File([fileData], basename(m.tmp_file), { type: mimetype }); var form = new FormData(); form.set("thumbnail", file); return pb.collection('vods').update(vodId, form); }); } console.log("All done."); } main()