import { removeMediaFile } from "../lib/mediaTier-server"; import path from "node:path"; import fs from "fs-extra"; import type { Paths } from "../lib/paths"; import { isDoNotClean } from "../lib/doNotClean-server"; import { audioFilesToRemove } from "../lib/videoStatus"; import { readChannelConfig } from "./channels"; const { pathExists, readdir, remove } = fs; export type CleanExtraAudioFormatsOptions = { channelSlug: string; paths: Paths; onLog?: (msg: string) => void; signal?: AbortSignal; }; export type CleanExtraAudioFormatsResult = { inspected: number; cleanedDirs: number; removedFiles: number; skipped: number; }; export async function cleanExtraAudioFormats({ channelSlug, paths, onLog, signal, }: CleanExtraAudioFormatsOptions): Promise { const log = onLog ?? ((m: string) => console.log(m)); const config = await readChannelConfig(paths, channelSlug); if (!config?.audioFormat) { log( `No audioFormat configured for ${channelSlug}; nothing to clean (set the channel's audio format first).`, ); return { inspected: 0, cleanedDirs: 0, removedFiles: 0, skipped: 0 }; } const targetAudioFile = `audio.${config.audioFormat}`; const dataDir = path.join(paths.channelsDir, channelSlug, "data"); if (!(await pathExists(dataDir))) { log(`No data directory for ${channelSlug}`); return { inspected: 0, cleanedDirs: 0, removedFiles: 0, skipped: 0 }; } const dirs = await readdir(dataDir); let cleanedDirs = 0; let removedFiles = 0; let skipped = 0; for (const id of dirs) { if (signal?.aborted) { log("Cancelled"); break; } const videoDir = path.join(dataDir, id); const entries = await readdir(videoDir).catch(() => [] as string[]); if (!entries.includes(targetAudioFile)) continue; // Shares the app's one media-file predicate rather than re-deriving it. The // inline copy that used to live here was a stale fork of the old denylist: // it excluded .tmp-/.info.json/.part and nothing else, so an // audio.en-orig.vtt subtitle or an audio.live_chat.json.part-Frag114 // fragment sitting next to the target format was deleted as an "extra audio // format" — by an unguarded remove(), in a sweep with no dry run. const extras = audioFilesToRemove(entries, { targetAudioFile, wrongFormatOnly: true, }); if (extras.length === 0) continue; if (await isDoNotClean(videoDir)) { log(`Skipped ${id} (marked do not clean)`); skipped++; continue; } for (const f of extras) { await removeMediaFile(videoDir, f); // derefs a tiered link (release 17) log(`Removed ${id}/${f}`); removedFiles++; } cleanedDirs++; } const skippedNote = skipped > 0 ? ` Skipped ${skipped} (do not clean).` : ""; log( `Cleaned ${removedFiles} extra audio file(s) from ${cleanedDirs} of ${dirs.length} video dir(s) (kept ${targetAudioFile}).${skippedNote}`, ); return { inspected: dirs.length, cleanedDirs, removedFiles, skipped }; }