Archilyzer · Source

archilyzer

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

commit c600686f70bd403eab11b027ed0b07c6eacbbc66
parent fd752389bd3e47b6d7feaf3573e64c317cb8452f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 07:24:41 -0400

sources: import a BitChute video politely; play its file; BitChute in the channel form and the duplicates page

- import-video with a bitchute.com URL (controller/bitchuteImport.ts): the
  URL becomes the video's canonical page (a channel or playlist page is
  refused with the way to take one), the job runs on platform:bitchute with
  BitChute's args whatever the channel's platform, nothing is asked while
  BitChute is held or cooling down, a video on disk is never fetched again,
  and the outcome is recorded on BitChute's pacing state (a 429 backs every
  BitChute path off, a clean download settles it)
- player: a BitChute record plays its mp4 in the native file player (seeks,
  highlights cues); a file that will not load degrades to a link to the page
- channel form: BitChute in both platform lists; duplicates: "BitChute"

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

Diffstat:
Mcommon/components/PlayerProvider.tsx | 14++++++++++----
Acommon/controller/bitchuteImport.test.ts | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/bitchuteImport.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/pipelineActions.ts | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Meditor/app/channels/components/ChannelForm.tsx | 2++
Mexport/app/duplicates/DuplicatesClient.tsx | 1+
6 files changed, 212 insertions(+), 13 deletions(-)

diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -55,12 +55,18 @@ const KickPlayer = dynamic(() => import("./KickPlayer"), { ssr: false, }); -// archive.org records play their file in a native <video> (FilePlayer): the -// embed takes no start time we can rely on, the file seeks. +// archive.org and BitChute records play their file in a native <video> +// (FilePlayer): neither embed takes a start time we can rely on, the file +// seeks. A file that will not load degrades to a link to the source page. const FilePlayer = dynamic(() => import("./FilePlayer"), { ssr: false, }); +// The platforms whose records play their own file (summary `mediaUrl`). +function playsFile(platform: Platform): boolean { + return platform === "archiveorg" || platform === "bitchute"; +} + type PlayerHandle = | Pick<ReactPlayerType, "seekTo"> | RumblePlayerHandle @@ -526,7 +532,7 @@ export function PlayerProvider({ const t = data.platform === "youtube" || data.platform === "kick" || - (data.platform === "archiveorg" && data.mediaUrl) + (playsFile(data.platform) && data.mediaUrl) ? currentTime : (urlTime ?? 0); const secs = Math.max(0, Math.floor(t)); @@ -1037,7 +1043,7 @@ export function PlayerProvider({ } onError={() => setKickError(true)} /> - ) : data.platform === "archiveorg" && data.mediaUrl && !fileError ? ( + ) : playsFile(data.platform) && data.mediaUrl && !fileError ? ( <FilePlayer key={data.id} ref={(p: FilePlayerHandle | null) => { diff --git a/common/controller/bitchuteImport.test.ts b/common/controller/bitchuteImport.test.ts @@ -0,0 +1,77 @@ +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test controller/bitchuteImport.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { importPlatformSignal, resolveBitchuteImportUrl } from "./bitchuteImport"; +import type { DownloadOutcomeRecord } from "../lib/downloadOutcome"; + +const ID = "Zq3xVb7Kp2Lm"; +const PAGE = `https://www.bitchute.com/video/${ID}/`; + +test("every URL of one video resolves to its canonical page", () => { + for (const url of [ + PAGE, + `https://www.bitchute.com/video/${ID}`, + `https://old.bitchute.com/video/${ID}/`, + `https://bitchute.com/embed/${ID}/`, + ]) { + assert.deepEqual(resolveBitchuteImportUrl(url), { ok: true, id: ID, url: PAGE }, url); + } +}); + +test("a channel or playlist page is refused with the way to take one", () => { + for (const url of [ + "https://www.bitchute.com/channel/examplechannel/", + "https://www.bitchute.com/playlist/Pl4yL1st0001/", + ]) { + const r = resolveBitchuteImportUrl(url); + assert.equal(r.ok, false, url); + assert.match((r as { error: string }).error, /make it a channel with that URL and sync it/); + } +}); + +function outcome(over: Partial<DownloadOutcomeRecord>): DownloadOutcomeRecord { + return { + videoId: ID, + status: "ok", + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:01:00.000Z", + attempts: [], + ...over, + }; +} + +test("a 429 backs BitChute off; a download that came down settles it; a per-video failure says nothing", () => { + assert.equal(importPlatformSignal(outcome({ status: "ok" })), "clean"); + assert.equal(importPlatformSignal(outcome({ status: "ok-auto-transcribed" })), "clean"); + assert.equal( + importPlatformSignal(outcome({ status: "failed", failureClass: "rate_limit" })), + "rate_limit", + ); + assert.equal( + importPlatformSignal(outcome({ status: "failed", failureClass: "network" })), + "network", + ); + // Classified from the last attempt when the record carries no class. + assert.equal( + importPlatformSignal( + outcome({ + status: "failed", + attempts: [ + { mode: "default", startedAt: "", finishedAt: "", error: "ERROR: [BitChute] x: HTTP Error 429: Too Many Requests" } as never, + ], + }), + ), + "rate_limit", + ); + assert.equal( + importPlatformSignal(outcome({ status: "failed", failureClass: "per_video" })), + null, + ); + assert.equal(importPlatformSignal(outcome({ status: "skipped-filtered" })), null); + // The media came down; only a subtitle fetch was refused. + assert.equal( + importPlatformSignal(outcome({ status: "ok", failureClass: "subs_rate_limit" })), + null, + ); +}); diff --git a/common/controller/bitchuteImport.ts b/common/controller/bitchuteImport.ts @@ -0,0 +1,52 @@ +// A one-off BitChute import (import-video with a bitchute.com URL), made polite. +// +// BitChute rate-limits, so an import of one of its videos — into whatever +// channel — runs the way a BitChute channel's own downloads do: on BitChute's +// queue (`platform:bitchute`, one transfer at a time), with BitChute's yt-dlp +// args (`PLATFORM_ARGS.bitchute`), never while BitChute is held or in a +// rate-limit cooldown, never for a video already on disk, and what BitChute +// answered is recorded on its shared pacing state, so a 429 backs every +// BitChute path off and a clean download settles it. The editor's +// importVideoAction does the wiring; these are its pure rules. + +import { classifyDownloadFailure } from "../lib/availability"; +import { defaultWebpageUrl } from "../lib/platform"; +import { extractVideoId } from "../lib/videoId"; +import type { DownloadOutcomeRecord } from "../lib/downloadOutcome"; + +export type BitchuteImportUrl = + | { ok: true; id: string; url: string } + | { ok: false; error: string }; + +// The canonical page of the video a BitChute URL names (www., old. and embed +// URLs all become https://www.bitchute.com/video/<id>/). A channel, playlist or +// profile page names no one video: it is refused with the way to list one. +export function resolveBitchuteImportUrl(url: string): BitchuteImportUrl { + const id = extractVideoId(url); + if (!id || !/^[\w-]+$/.test(id)) { + return { + ok: false, + error: + "A BitChute import takes one video's page (https://www.bitchute.com/video/<id>/). " + + "To take a whole BitChute channel, make it a channel with that URL and sync it.", + }; + } + return { ok: true, id, url: defaultWebpageUrl("bitchute", id) }; +} + +// What one import's outcome tells BitChute's pacing state: a rate limit or a +// network failure backs the platform off, a download that came down settles +// it, and anything else (a per-video failure, a filter skip) tells it nothing. +export function importPlatformSignal( + outcome: Pick<DownloadOutcomeRecord, "status" | "failureClass" | "attempts">, +): "rate_limit" | "network" | "clean" | null { + if (outcome.status.startsWith("ok")) { + return outcome.failureClass === "subs_rate_limit" ? null : "clean"; + } + if (outcome.status !== "failed") return null; + const last = outcome.attempts[outcome.attempts.length - 1]; + const cls = + outcome.failureClass ?? + classifyDownloadFailure(last?.error ?? "", last?.availabilityClass); + return cls === "rate_limit" || cls === "network" ? cls : null; +} diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -21,6 +21,10 @@ import { runArchiveOrgImport, } from "yt-dlp-transcript-common/controller/archiveOrgImport"; import { + importPlatformSignal, + resolveBitchuteImportUrl, +} from "yt-dlp-transcript-common/controller/bitchuteImport"; +import { heldPlatformRefusal, platformCooldownRemainingMs, recordDownloadBackoff, @@ -519,7 +523,14 @@ export async function importVideoAction( // BitTorrent when it can, else straight from archive.org, never yt-dlp // (controller/archiveOrgDownload.ts) — and a file already on disk is not // fetched again. - const archiveOrg = detectPlatform(videoUrl) === "archiveorg"; + const urlPlatform = detectPlatform(videoUrl); + const archiveOrg = urlPlatform === "archiveorg"; + // A BitChute URL likewise (controller/bitchuteImport.ts): canonicalized, run + // on BitChute's own queue with BitChute's args whatever the channel's + // platform, refused while BitChute is held or cooling down, never fetched + // again when on disk — and what BitChute answers is recorded on its pacing + // state below. + const bitchute = urlPlatform === "bitchute"; let downloadConfig = channelConfig; if (archiveOrg) { const refused = await archiveOrgRefusal(paths, "The import"); @@ -544,6 +555,29 @@ export async function importVideoAction( downloadConfig = { ...channelConfig, platform: "archiveorg" }; } } + if (bitchute) { + const refused = await platformRefusal(paths, "bitchute", "BitChute", "The import"); + if (refused) return { ok: false, info: true, error: refused }; + const resolved = resolveBitchuteImportUrl(videoUrl); + if (!resolved.ok) return { ok: false, error: resolved.error }; + videoUrl = resolved.url; + if ( + await destinationExists( + path.join(paths.channelsDir, slug, "data"), + resolved.id, + channelConfig.handling, + ) + ) { + return { + ok: false, + info: true, + error: `Already downloaded: data/${resolved.id}/ — BitChute is not asked for it again.`, + }; + } + if (channelConfig.platform !== "bitchute") { + downloadConfig = { ...channelConfig, platform: "bitchute" }; + } + } // Best-effort canonical id: used only for revalidation/labels. When null, // downloadOneManaged falls back to %(id)s and the reconcile pass repairs the // dir, so we don't hard-fail here. @@ -554,7 +588,9 @@ export async function importVideoAction( return runManagedFunction({ kind: "import-one", queueKey: resolveQueueKey( - archiveOrg ? platformQueueKey("archiveorg") : downloadQueueKey(channelConfig), + archiveOrg || bitchute + ? platformQueueKey(urlPlatform) + : downloadQueueKey(channelConfig), queueKey, ), paths, @@ -567,7 +603,7 @@ export async function importVideoAction( kind: "download", }); try { - await downloadOneManaged({ + const outcome = await downloadOneManaged({ channelSlug: slug, channelConfig: downloadConfig, paths, @@ -579,6 +615,22 @@ export async function importVideoAction( globalSkipLiveDownloads: settings.skipLiveDownloads, appendArchive: true, }); + if (bitchute) { + // BitChute's shared pacing state learns what it said: a 429 backs + // every BitChute path off (the lane, a sync, the next import). + // Best-effort, as the batch downloads' bookkeeping is. + const answer = importPlatformSignal(outcome); + try { + if (answer === "rate_limit" || answer === "network") { + await recordDownloadBackoff("bitchute", paths, answer); + } else if (answer === "clean") { + const line = await recordPlatformClean("bitchute", paths); + if (line) task.onLog(line); + } + } catch { + /* shared-state write is best-effort */ + } + } // ADD-ONLY: record the imported video in the roster so a one-off import // is a known member of the channel rather than an orphan dir, and so a // later retry of the same URL is possible even if it never appears in a @@ -604,18 +656,27 @@ export async function importVideoAction( }); } -// archive.org held, or in a rate-limit cooldown: a sentence, else null. An -// import asks archive.org nothing while it has asked us to wait. -async function archiveOrgRefusal(paths: Paths, what: string): Promise<string | null> { - const held = await heldPlatformRefusal("archiveorg", what, paths); +// A platform held, or in a rate-limit cooldown: a sentence, else null. An +// import asks a platform nothing while it has asked us to wait. +async function platformRefusal( + paths: Paths, + platform: string, + label: string, + what: string, +): Promise<string | null> { + const held = await heldPlatformRefusal(platform, what, paths); if (held) return held; - const remainingMs = await platformCooldownRemainingMs("archiveorg", paths); + const remainingMs = await platformCooldownRemainingMs(platform, paths); if (remainingMs > 0) { - return `archive.org is in a rate-limit cooldown (${Math.ceil(remainingMs / 1000)}s remaining). ${what} can run once it lapses.`; + return `${label} is in a rate-limit cooldown (${Math.ceil(remainingMs / 1000)}s remaining). ${what} can run once it lapses.`; } return null; } +function archiveOrgRefusal(paths: Paths, what: string): Promise<string | null> { + return platformRefusal(paths, "archiveorg", "archive.org", what); +} + // IMPORT CHOSEN FILES OF ONE archive.org ITEM (`pnpm ops import-archive-org`): // one job on archive.org's own queue that imports the files one at a time, // with a jittered pause between them, skipping any already downloaded, and diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx @@ -385,6 +385,7 @@ export function ChannelForm({ <option value="twitch">Twitch</option> <option value="kick">Kick</option> <option value="archiveorg">archive.org</option> + <option value="bitchute">BitChute</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> <option value="xenforo">Forum thread — XenForo (posts)</option> @@ -506,6 +507,7 @@ export function ChannelForm({ <option value="twitch">Twitch</option> <option value="kick">Kick</option> <option value="archiveorg">archive.org</option> + <option value="bitchute">BitChute</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> <option value="xenforo">Forum thread — XenForo (posts)</option> diff --git a/export/app/duplicates/DuplicatesClient.tsx b/export/app/duplicates/DuplicatesClient.tsx @@ -32,6 +32,7 @@ const PLATFORM_LABEL: Record<string, string> = { twitch: "Twitch", kick: "Kick", archiveorg: "archive.org", + bitchute: "BitChute", }; const MATCH_LABEL: Record<DuplicateCluster["matchKind"], string> = {