Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit df0d898c0c4f45606a1cc864e9988465ff1a119e
parent 90ab386251e27c00283a58eb3aaaadabd028f594
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:09:37 -0400

editor: Persist source video quality select; source video quality in settings and channel forms

The persist control starts on the effective quality (channel over global)
and passes the choice to redownloadToArchiveAction; fetchFullSourceAction
takes an optional quality and otherwise resolves the same way. The source
video card shows a persisted container's format.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 6++++++
Meditor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 5+++++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 32++++++++++++++++++++++++++++----
Meditor/app/channels/[slug]/videos/page.tsx | 6++++++
Meditor/app/channels/components/ChannelForm.tsx | 24++++++++++++++++++++++++
Meditor/app/channels/components/channelConfigToForm.ts | 2++
Meditor/app/channels/components/parseChannelForm.ts | 11++++++++++-
Meditor/app/settings/actions.ts | 13++++++++++++-
Meditor/app/settings/components/SettingsForm.tsx | 26++++++++++++++++++++++++++
11 files changed, 177 insertions(+), 14 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A source video can be saved at 720p for clip and editing work.** **Persist source video** on a video's page has a **Quality** select: **Original** (the best video and audio, as every persist has been) or **Video 720p (H.264, for clips/editing)**, which saves an H.264 mp4 at most 720p tall — smaller, and quick to cut. When a source has nothing at or under 720p in H.264 it takes 480p, and when it has neither it takes whatever is best and says so in the job's log, with the height it got. The default is the new **Source video quality** under **Settings**, which a channel can override in its Advanced settings; the whole-recording fetch (`full: true`) and **Persist kept now** follow the channel's, else the global, choice. A persisted video's **Source video** card now shows the format that was saved (height and codec) and the quality asked for; videos persisted before this show nothing new. "Video 720p" is also offered as a download format. - **X posts are fetched more slowly, with random gaps.** Every read of X now waits a random 4 to 10 seconds before each request to X, where it used to page as fast as X answered, and always waits out a rate limit rather than pushing through. When fetching older posts, the pause between one three-month window and the next is a random 45 to 120 seconds instead of a fixed 15. A deep walk of an account's history takes longer; a routine fetch of new posts takes a few seconds more. - **The MCP's search tools take `date_from` and `date_to` as `2024-10-26` as well as `20241026`, and refuse a date they cannot read.** `search_transcripts` and `enumerate_matches` used to accept only `YYYYMMDD`: any other spelling was dropped with a footer warning and the search ran with no date bound, so a whole-corpus count could be read as the bounded one. Dashed, slashed and dotted dates and ISO timestamps are now normalised, and anything else is an error and nothing is searched. - **An X channel can fetch posts older than its timeline reaches.** X's timeline only pages back so far, so a fetch could end, and call the history done, well short of an account's first post. The new **Fetch older posts** button on an X channel's page (or `pnpm ops fetch-posts --json '{"slug":"<channel>","older":true}'`) walks back from the oldest archived post through X search, three months at a time, and saves posts the same way a normal fetch does; posts already archived are skipped. It needs a login, as search does: without one it stops at once and the channel shows **Needs credentials**. A run saves its place as it goes and stops after three hours; the next run continues from there. The walk ends at the account's creation date, after a year of windows with no posts, or at a date you give as `"floor": "YYYY-MM-DD"`, and the page's **Older posts** line then says it is complete; running it again says so and fetches nothing. A normal **Fetch posts** is unaffected and still fetches new posts from the top. Bluesky channels have no such button: their fetch already reads the whole history. diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx @@ -14,6 +14,7 @@ import type { } from "yt-dlp-transcript-common/lib/channelConfig"; import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome"; import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo"; +import type { SourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability"; import type { SubtitleProvenance } from "yt-dlp-transcript-common/lib/subtitleProvenance"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; @@ -73,6 +74,9 @@ type Props = { // This video's saved-video pointer when its source container is persisted to // the store, else null. Drives the Source-video persistence card (Phase 5). savedVideo?: SavedVideoPointer | null; + // The effective source-video quality for this channel (channel over global, + // resolveSourceVideoQuality): where the persist control's select starts. + sourceVideoQuality?: SourceVideoQuality; // Windows of this video's source media another tool asked the editor to // fetch, into data/<id>/clips/. Empty for almost every video. clipWindows?: FetchedWindow[]; @@ -108,6 +112,7 @@ export function VideoPanel({ doNotClean = false, excludedFromTruncatedCheck = false, savedVideo = null, + sourceVideoQuality, clipWindows = [], coverage = null, prevHref, @@ -374,6 +379,7 @@ export function VideoPanel({ defaultQueueKey={defaultQueueKey} existingQueues={existingQueues} savedVideo={savedVideo} + defaultQuality={sourceVideoQuality} /> </PipelineStageCard> diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SourceVideoSection.tsx @@ -5,6 +5,12 @@ import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActio import type { ChannelHandling } from "yt-dlp-transcript-common/lib/channelConfig"; import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; +import { + DEFAULT_SOURCE_VIDEO_QUALITY, + SOURCE_VIDEO_QUALITIES, + SOURCE_VIDEO_QUALITY_LABELS, + type SourceVideoQuality, +} from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { QueueControl } from "../../../../../../components/QueueControl"; import { cancelJobAction } from "../../../../../../jobs/actions"; import { @@ -19,6 +25,7 @@ export function SourceVideoSection({ defaultQueueKey, existingQueues, savedVideo, + defaultQuality = DEFAULT_SOURCE_VIDEO_QUALITY, }: { slug: string; videoId: string; @@ -26,8 +33,12 @@ export function SourceVideoSection({ defaultQueueKey: string; existingQueues: string[]; savedVideo: SavedVideoPointer | null; + // The effective source-video quality (channel over global), resolved by the + // page through resolveSourceVideoQuality: the select starts on it. + defaultQuality?: SourceVideoQuality; }) { const [queueKey, setQueueKey] = useState(defaultQueueKey); + const [quality, setQuality] = useState<SourceVideoQuality>(defaultQuality); // Set the moment a persist run STARTS from this surface. That run's own // router.refresh() lands the saved-video pointer, and the persisted view used // to REPLACE this whole subtree — destroying the StreamActionLog below and the @@ -74,20 +85,44 @@ export function SourceVideoSection({ disabled={!!savedVideo} trigger={() => { setRanHere(true); - return redownloadToArchiveAction(slug, videoId, queueKey); + return redownloadToArchiveAction( + slug, + videoId, + queueKey, + quality, + ); }} cancelAction={cancelJobAction} buttonLabel="Persist source video" runningLabel="Archiving…" label={`Persist source video for ${videoId}`} extraControls={ - <QueueControl - value={queueKey} - onChange={setQueueKey} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel={`Persist source video for ${videoId}`} - /> + <> + <label className="flex items-center gap-2 text-sm"> + Quality + <select + value={quality} + onChange={(e) => + setQuality(e.target.value as SourceVideoQuality) + } + aria-label="source video quality" + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + {SOURCE_VIDEO_QUALITIES.map((q) => ( + <option key={q} value={q}> + {SOURCE_VIDEO_QUALITY_LABELS[q]} + </option> + ))} + </select> + </label> + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel={`Persist source video for ${videoId}`} + /> + </> } /> )} @@ -123,6 +158,20 @@ function PersistedSourceVideo({ <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd> <dt className="text-muted-foreground">Stored</dt> <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd> + {/* WHAT WAS KEPT: the quality asked for and the format yt-dlp took. + Absent on a pointer written before the quality setting. */} + {savedVideo.format && ( + <> + <dt className="text-muted-foreground">Format</dt> + <dd> + {savedVideo.format.height + ? `${savedVideo.format.height}p` + : "unknown height"} + {savedVideo.format.vcodec ? ` ${savedVideo.format.vcodec}` : ""} + {` (${SOURCE_VIDEO_QUALITY_LABELS[savedVideo.format.preset]})`} + </dd> + </> + )} {savedVideo.keepReason && ( <> <dt className="text-muted-foreground">Reason</dt> diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx @@ -32,6 +32,7 @@ import { } from "yt-dlp-transcript-common/lib/platform"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { resolveSourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { liveJobRows } from "../../../../jobs/active/buildActiveJobs"; import { RunningJobsList } from "../../../../jobs/components/RunningJobsList"; import { VideoPanel, type VideoFile } from "./components/VideoPanel"; @@ -309,6 +310,10 @@ export default async function VideoDetailPage({ doNotClean={doNotClean} excludedFromTruncatedCheck={excludedFromTruncatedCheck} savedVideo={savedVideo} + sourceVideoQuality={resolveSourceVideoQuality({ + channel: config.sourceVideoQuality, + global: getSettings().sourceVideoQuality, + })} clipWindows={clipWindows.map((w) => ({ file: w.file, from: w.from, diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -13,7 +13,9 @@ import { AUDIO_FORMAT_VALUES } from "yt-dlp-transcript-common/lib/channelConfig" import { isDownloadFormatPreset, resolveDownloadFormatPreset, + resolveSourceVideoQuality, type DownloadFormatPreset, + type SourceVideoQuality, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; import { diskGate } from "yt-dlp-transcript-common/lib/diskSpace"; @@ -255,13 +257,16 @@ export async function downloadVideoPipelineAction( // media pass is otherwise skipped when a transcript or captions exist; there, // with a transcript on disk, no audio is extracted — the container is the // point. Works on a video already in the archive — downloadOneManaged has no -// archive prefilter, so it always re-downloads. +// archive prefilter, so it always re-downloads. `quality` is the control's +// per-persist choice ("original" / "video_720"); anything else, or nothing, +// inherits the channel's, else the global, source-video quality. export async function redownloadToArchiveAction( slug: string, videoId: string, queueKey?: string, + quality?: string, ): Promise<StreamActionResult> { - return archiveSourceVideo(slug, videoId, queueKey); + return archiveSourceVideo(slug, videoId, queueKey, undefined, quality); } // The body of both "Persist source video" (the button) and the full-source @@ -274,6 +279,9 @@ async function archiveSourceVideo( videoId: string, queueKey: string | undefined, persistOrigin?: SavedVideoOrigin, + // The per-persist quality override, unvalidated: resolveSourceVideoQuality + // drops anything that is not a quality, falling through to channel/global. + qualityOverride?: unknown, ): Promise<StreamActionResult> { const r = await loadConfigOrError(slug); if (!r.ok) return r; @@ -289,6 +297,11 @@ async function archiveSourceVideo( const err = await lowDiskError(paths, slug); if (err) return err; const settings = getSettings(); + const persistFormatPreset = resolveSourceVideoQuality({ + override: qualityOverride, + channel: r.config.sourceVideoQuality, + global: settings.sourceVideoQuality, + }); return runManagedFunction({ kind: "redownload-archive", queueKey: videoQueueKey(r.config, queueKey), @@ -305,7 +318,9 @@ async function archiveSourceVideo( if (persistOrigin) { onLog(`${requesterLine(persistOrigin)}\n`); } - onLog(`Re-downloading ${videoId} to archive its source video…\n`); + onLog( + `Re-downloading ${videoId} to archive its source video (quality ${persistFormatPreset})…\n`, + ); const record = await downloadOneManaged({ channelSlug: slug, channelConfig: r.config, @@ -324,6 +339,7 @@ async function archiveSourceVideo( // got two subtitle passes and no file (release 10 slice N). forceMedia: true, persistOrigin, + persistFormatPreset, }); safeRevalidate([ `/channels/${slug}/videos/${videoId}`, @@ -1153,6 +1169,8 @@ export async function fetchFullSourceAction(req: { videoId: string; provenance: FetchWindowProvenance; queueKey?: string; + // Per-fetch quality; absent = the channel's, else the global setting. + quality?: SourceVideoQuality; }): Promise<FetchMediaOutcome> { const { slug, videoId } = req; const r = await loadConfigOrError(slug); @@ -1209,7 +1227,13 @@ export async function fetchFullSourceAction(req: { ...(req.provenance.reason ? { reason: req.provenance.reason } : {}), requestedAt: req.provenance.requestedAt ?? new Date().toISOString(), }; - const res = await archiveSourceVideo(slug, videoId, req.queueKey, origin); + const res = await archiveSourceVideo( + slug, + videoId, + req.queueKey, + origin, + req.quality, + ); if (!res.ok) { // archiveSourceVideo's own low-disk refusal is the only 507-shaped one it // returns; everything else is a lookup failure or an unreachable drive. diff --git a/editor/app/channels/[slug]/videos/page.tsx b/editor/app/channels/[slug]/videos/page.tsx @@ -21,6 +21,8 @@ import { TRANSCRIPTION_QUEUE, } from "yt-dlp-transcript-common/lib/platform"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { resolveSourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { VideoPanel, type VideoFile } from "../videos/[id]/components/VideoPanel"; import { VideoWorkspace } from "./components/VideoWorkspace"; import { readChannelConfigCached } from "../lib/channelConfigCache"; @@ -255,6 +257,10 @@ export default async function ChannelVideosPage({ availabilityHistory={availabilityRecord?.history ?? []} channelAudioFormat={config.audioFormat} excludedFromTruncatedCheck={excludedTrunc} + sourceVideoQuality={resolveSourceVideoQuality({ + channel: config.sourceVideoQuality, + global: getSettings().sourceVideoQuality, + })} coverage={coverage} // The standalone /videos/[id] page has no list to walk, which is why // the embedded pane keeps prev/next and the position readout. diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -12,6 +12,8 @@ import { import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, + SOURCE_VIDEO_QUALITIES, + SOURCE_VIDEO_QUALITY_LABELS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS, @@ -641,6 +643,28 @@ export function ChannelForm({ audio in their original file (e.g. Odysee). </span> </label> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Source video quality</span> + <SeededSelect + state={state} + name="sourceVideoQuality" + initial={c?.sourceVideoQuality ?? ""} + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="">Inherit (global default)</option> + {SOURCE_VIDEO_QUALITIES.map((q) => ( + <option key={q} value={q}> + {SOURCE_VIDEO_QUALITY_LABELS[q]} + </option> + ))} + </SeededSelect> + <span className="text-xs text-muted-foreground"> + What a full persist of this channel&apos;s videos keeps + (&ldquo;Persist source video&rdquo;, a whole-recording fetch, + &ldquo;Persist kept now&rdquo;). <strong>720p</strong> saves a + smaller H.264 file that is quick to cut for clips and editing. + </span> + </label> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" diff --git a/editor/app/channels/components/channelConfigToForm.ts b/editor/app/channels/components/channelConfigToForm.ts @@ -37,6 +37,7 @@ export const CHANNEL_FORM_VALUES = [ "socialHandle", "audioFormat", "downloadFormat", + "sourceVideoQuality", "keepLatest", "extractionMode", "savedVideosDir", @@ -87,6 +88,7 @@ export function channelConfigToFormData(config: ChannelConfig): FormData { put(fd, "socialHandle", config.socialHandle); put(fd, "audioFormat", config.audioFormat); put(fd, "downloadFormat", config.downloadFormat); + put(fd, "sourceVideoQuality", config.sourceVideoQuality); if (config.keepSourceVideo) fd.set("keepSourceVideo", "on"); putNum(fd, "keepLatest", config.keepLatest); put(fd, "extractionMode", config.extractionMode); diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts @@ -20,7 +20,10 @@ import { } from "yt-dlp-transcript-common/lib/platform"; import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers"; import { isCookieMode } from "yt-dlp-transcript-common/lib/cookiePolicy"; -import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; +import { + isDownloadFormatPreset, + isSourceVideoQuality, +} from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { downloadFilterPatternProblem } from "yt-dlp-transcript-common/lib/downloadFilters"; import { isRejectedLivestreamMode } from "yt-dlp-transcript-common/lib/channelConfig"; @@ -42,6 +45,7 @@ export const CHANNEL_FORM_FIELDS = [ "url", "audioFormat", "downloadFormat", + "sourceVideoQuality", "keepSourceVideo", "keepLatest", "extractionMode", @@ -112,6 +116,10 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm { const downloadFormatRaw = String(formData.get("downloadFormat") ?? ""); const downloadFormat: ChannelConfig["downloadFormat"] | undefined = isDownloadFormatPreset(downloadFormatRaw) ? downloadFormatRaw : undefined; + // Blank = inherit the global sourceVideoQuality (omit the key). + const sourceVideoQualityRaw = String(formData.get("sourceVideoQuality") ?? ""); + const sourceVideoQuality: ChannelConfig["sourceVideoQuality"] | undefined = + isSourceVideoQuality(sourceVideoQualityRaw) ? sourceVideoQualityRaw : undefined; const keepSourceVideo = formData.get("keepSourceVideo") != null; // Keep-latest retention/persistence window. Blank = inherit-off (omit), so a @@ -342,6 +350,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm { if (url) config.url = url; if (audioFormat) config.audioFormat = audioFormat; if (downloadFormat) config.downloadFormat = downloadFormat; + if (sourceVideoQuality) config.sourceVideoQuality = sourceVideoQuality; if (keepSourceVideo) config.keepSourceVideo = true; if (keepLatest != null) config.keepLatest = keepLatest; if (extractionMode) config.extractionMode = extractionMode; diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -25,7 +25,11 @@ import { DEFAULT_COOKIE_MODE, isCookieMode, } from "yt-dlp-transcript-common/lib/cookiePolicy"; -import { isDownloadFormatPreset } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; +import { + DEFAULT_SOURCE_VIDEO_QUALITY, + isDownloadFormatPreset, + isSourceVideoQuality, +} from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { formValues, type FormState } from "../lib/formState"; // THE SHARED SAVE RESULT of /settings and the four operation-settings forms. A @@ -80,6 +84,12 @@ export async function saveSettingsAction( const downloadFormat = isDownloadFormatPreset(downloadFormatRaw) ? downloadFormatRaw : "auto"; + const sourceVideoQualityRaw = String( + formData.get("sourceVideoQuality") ?? "", + ).trim(); + const sourceVideoQuality = isSourceVideoQuality(sourceVideoQualityRaw) + ? sourceVideoQualityRaw + : DEFAULT_SOURCE_VIDEO_QUALITY; if (!adminTitle) { return { ok: false, error: "Admin title is required", values }; @@ -230,6 +240,7 @@ export async function saveSettingsAction( cookieMode, sleepBetweenDownloadsSeconds: sleepParsed, downloadFormat, + sourceVideoQuality, minFreeDiskGB: minFreeDiskParsed, resumeMarginGB: resumeMarginParsed, // Vestigial (the worker list is the parallelism; kept only for rollback), diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -10,6 +10,8 @@ import type { SiteSettings } from "yt-dlp-transcript-common/lib/settings"; import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, + SOURCE_VIDEO_QUALITIES, + SOURCE_VIDEO_QUALITY_LABELS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { Field, @@ -130,6 +132,30 @@ export function SettingsForm({ initial }: Props) { Advanced settings, and a single re-download can override it too. </span> </label> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Source video quality</span> + <SeededSelect + state={state} + name="sourceVideoQuality" + initial={initial.sourceVideoQuality} + className="rounded border border-border bg-card px-2 py-1 text-sm" + > + {SOURCE_VIDEO_QUALITIES.map((q) => ( + <option key={q} value={q}> + {SOURCE_VIDEO_QUALITY_LABELS[q]} + </option> + ))} + </SeededSelect> + <span className="text-xs text-muted-foreground"> + What a full persist keeps: &ldquo;Persist source video&rdquo;, a + whole-recording fetch and &ldquo;Persist kept now&rdquo;.{" "} + <strong>Original</strong> takes the best video and audio;{" "} + <strong>720p</strong> takes an H.264 file at most 720p tall (480p + when there is none), smaller and quick to cut for clips and editing. + Each channel can override this in its Advanced settings, and a single + persist can override it too. + </span> + </label> <Field label="Minimum free disk space (GB)" name="minFreeDiskGB"