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:
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> = {