From d5237484318942f3a0e8087f9561287dc70fd1e1 Mon Sep 17 00:00:00 2001
From: lmdev <138343225+lmdevv@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:39:13 -0400
Subject: [PATCH] web: add transcript download controls
---
web/i18n/en/button.json | 1 +
web/i18n/en/dialog.json | 1 +
web/i18n/en/error/api.json | 5 +-
web/i18n/en/save.json | 1 +
web/i18n/en/settings.json | 13 +++++-
web/src/components/dialog/SavingDialog.svelte | 28 +++++++++--
web/src/components/save/Omnibox.svelte | 9 ++++
web/src/lib/api/saving-handler.ts | 14 ++++++
web/src/lib/download.ts | 28 ++++++++---
web/src/lib/i18n/translations.ts | 1 +
web/src/lib/settings/defaults.ts | 5 +-
web/src/lib/settings/migrate.ts | 7 +++
web/src/lib/settings/validate.ts | 4 ++
web/src/lib/types/api.ts | 4 +-
web/src/lib/types/dialog.ts | 1 +
web/src/lib/types/settings.ts | 6 ++-
web/src/lib/types/settings/v2.ts | 2 +-
web/src/lib/types/settings/v7.ts | 14 ++++++
web/src/routes/settings/metadata/+page.svelte | 46 ++++++++++++++++++-
19 files changed, 170 insertions(+), 20 deletions(-)
create mode 100644 web/src/lib/types/settings/v7.ts
diff --git a/web/i18n/en/button.json b/web/i18n/en/button.json
index 643f4b8e..71d4365f 100644
--- a/web/i18n/en/button.json
+++ b/web/i18n/en/button.json
@@ -7,6 +7,7 @@
"download": "download",
"share": "share",
"copy": "copy",
+ "copy.text": "copy text",
"copy.section": "copy the section link",
"copied": "copied",
"import": "import",
diff --git a/web/i18n/en/dialog.json b/web/i18n/en/dialog.json
index 44e92dab..70304ee6 100644
--- a/web/i18n/en/dialog.json
+++ b/web/i18n/en/dialog.json
@@ -10,6 +10,7 @@
"saving.title": "choose how to save",
"saving.blocked": "cobalt tried opening the file in a new tab, but your browser blocked it. you can allow pop-ups for cobalt to prevent this from happening next time.",
"saving.timeout": "cobalt tried saving the file automatically, but your browser stopped it. you have to select a preferred method manually.",
+ "saving.copy_failed": "your browser wouldn't let cobalt copy the transcript. check clipboard permissions and try again.",
"safety.title": "important safety notice",
diff --git a/web/i18n/en/error/api.json b/web/i18n/en/error/api.json
index 186b45a4..fcd0cbec 100644
--- a/web/i18n/en/error/api.json
+++ b/web/i18n/en/error/api.json
@@ -25,6 +25,7 @@
"service.unsupported": "this service is not supported yet. have you pasted the right link?",
"service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!",
"service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!",
+ "service.captions_not_supported": "standalone transcripts are only available for youtube links right now.",
"link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?",
"link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",
@@ -59,5 +60,7 @@
"youtube.api_error": "youtube updated something about its api and i couldn't get any info about this video. try again in a few seconds, but if this issue sticks, please report it!",
"youtube.disabled_main_instance": "youtube downloading is disabled on the main instance due to restrictions from youtube's side and infinite maintenance cost at scale.\n\nwe apologize for the inconvenience and encourage you to host your own API instance for this.",
"youtube.drm": "this youtube video is protected by widevine DRM, so i can't download it. try a different link!",
- "youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!"
+ "youtube.no_session_tokens": "couldn't get required session tokens for youtube. this may be caused by a restriction on youtube's side. try again in a few seconds, but if this issue sticks, please report it!",
+ "youtube.captions_unavailable": "this video doesn't have any captions i can download.",
+ "youtube.caption_language_unavailable": "this video doesn't have captions in the selected language. available languages: {{ languages }}."
}
diff --git a/web/i18n/en/save.json b/web/i18n/en/save.json
index 91716ab4..603dcfb0 100644
--- a/web/i18n/en/save.json
+++ b/web/i18n/en/save.json
@@ -4,6 +4,7 @@
"auto": "auto",
"audio": "audio",
"mute": "mute",
+ "captions": "transcript",
"input.placeholder": "paste the link here",
"terms.note.agreement": "by continuing, you agree to",
"terms.note.link": "terms and ethics of use",
diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json
index 6f0463bd..5a263dac 100644
--- a/web/i18n/en/settings.json
+++ b/web/i18n/en/settings.json
@@ -65,8 +65,19 @@
"subtitles": "subtitles",
"subtitles.title": "preferred subtitle language",
- "subtitles.description": "cobalt will add subtitles to the downloaded file in the preferred language if they're available.\n\nsome services don't have a language selection, and if that's the case, cobalt will add the only available subtitle track if you have any language selected.",
+ "subtitles.description": "cobalt will add subtitles to downloaded media in the preferred language when they're available.",
"subtitles.none": "none",
+ "captions": "transcripts",
+ "captions.format": "transcript file format",
+ "captions.format.description": "plain text and markdown omit timestamps. webvtt and subrip keep caption timing.",
+ "captions.format.txt": "plain text (.txt)",
+ "captions.format.vtt": "webvtt (.vtt)",
+ "captions.format.srt": "subrip (.srt)",
+ "captions.format.md": "markdown (.md)",
+ "captions.method": "transcript saving method",
+ "captions.method.description": "how to save transcripts from the save button. \"ask\" shows both options every time.",
+ "captions.language": "preferred transcript language",
+ "captions.language.description": "cobalt will pick the transcript in this language when the video has multiple caption tracks. otherwise it falls back to the video's default track.",
"audio.youtube.better_audio": "youtube audio quality",
"audio.youtube.better_audio.title": "prefer better quality",
diff --git a/web/src/components/dialog/SavingDialog.svelte b/web/src/components/dialog/SavingDialog.svelte
index 6fa1ed49..f6dacf3c 100644
--- a/web/src/components/dialog/SavingDialog.svelte
+++ b/web/src/components/dialog/SavingDialog.svelte
@@ -5,6 +5,7 @@
import { hapticConfirm } from "$lib/haptics";
import {
copyURL,
+ copyTextFromURL,
openURL,
shareURL,
openFile,
@@ -33,10 +34,12 @@
export let url: string = "";
export let file: File | undefined = undefined;
export let urlType: CobaltFileUrlType | undefined = undefined;
+ export let copyTextURL = "";
let close: () => void;
let copied = false;
+ let copyFailed = false;
$: if (copied) {
setTimeout(() => {
@@ -103,15 +106,24 @@
elevated
click={async () => {
if (!copied) {
- copyURL(url);
- hapticConfirm();
- copied = true;
+ try {
+ if (copyTextURL) {
+ await copyTextFromURL(copyTextURL);
+ } else {
+ await copyURL(url);
+ }
+ hapticConfirm();
+ copied = true;
+ copyFailed = false;
+ } catch {
+ copyFailed = true;
+ }
}
}}
ariaLabel={copied ? $t("button.copied") : ""}
>
-
- {$t("button.copy")}
+
+ {$t(copyTextURL ? "button.copy.text" : "button.copy")}
{/if}
@@ -125,6 +137,12 @@
{bodyText}
{/if}
+
+ {#if copyFailed}
+
+ {$t("dialog.saving.copy_failed")}
+
+ {/if}
;
@@ -223,6 +224,14 @@
{$t("save.mute")}
+
+
+ {$t("save.captions")}
+
diff --git a/web/src/lib/api/saving-handler.ts b/web/src/lib/api/saving-handler.ts
index 94c0319e..3a004ebf 100644
--- a/web/src/lib/api/saving-handler.ts
+++ b/web/src/lib/api/saving-handler.ts
@@ -49,6 +49,7 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
alwaysProxy: getSetting("save", "alwaysProxy"),
downloadMode: getSetting("save", "downloadMode"),
+ captionFormat: getSetting("save", "captionFormat"),
subtitleLang: getSetting("save", "subtitleLang"),
filenameStyle: getSetting("save", "filenameStyle"),
@@ -69,6 +70,13 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
convertGif: getSetting("save", "convertGif"),
}
+ if (selectedRequest.downloadMode === "captions") {
+ const captionLang = getSetting("save", "captionLang");
+ if (captionLang && captionLang !== "none") {
+ selectedRequest.captionLanguage = captionLang;
+ }
+ }
+
const response = await API.request(selectedRequest);
if (!response) {
@@ -103,6 +111,12 @@ export const savingHandler = async ({ url, request, oldTaskId }: SavingHandlerAr
return downloadFile({
url: response.url,
+ method: selectedRequest.downloadMode === "captions"
+ ? getSetting("save", "transcriptMethod")
+ : undefined,
+ copyTextURL: selectedRequest.downloadMode === "captions"
+ ? response.text || response.url
+ : undefined,
});
} else {
downloadButtonState.set("error");
diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts
index efcf50ee..1a727c9c 100644
--- a/web/src/lib/download.ts
+++ b/web/src/lib/download.ts
@@ -8,11 +8,14 @@ import { createDialog } from "$lib/state/dialogs";
import type { DialogInfo } from "$lib/types/dialog";
import type { CobaltFileUrlType } from "$lib/types/api";
+import type { CobaltSettings } from "$lib/types/settings";
type DownloadFileParams = {
url?: string,
file?: File,
urlType?: CobaltFileUrlType,
+ copyTextURL?: string,
+ method?: CobaltSettings['save']['transcriptMethod'],
}
type SavingDialogParams = {
@@ -20,15 +23,17 @@ type SavingDialogParams = {
file?: File,
body?: string,
urlType?: CobaltFileUrlType,
+ copyTextURL?: string,
}
-const openSavingDialog = ({ url, file, body, urlType }: SavingDialogParams) => {
+const openSavingDialog = ({ url, file, body, urlType, copyTextURL }: SavingDialogParams) => {
const dialogData: DialogInfo = {
type: "saving",
id: "saving",
file,
url,
urlType,
+ copyTextURL,
}
if (body) dialogData.bodyText = body;
@@ -75,13 +80,19 @@ export const copyURL = async (url: string) => {
return await navigator?.clipboard?.writeText(url);
}
-export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
+export const copyTextFromURL = async (url: string) => {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error("couldn't fetch transcript");
+ return await navigator?.clipboard?.writeText(await response.text());
+}
+
+export const downloadFile = async ({ url, file, urlType, copyTextURL, method }: DownloadFileParams) => {
if (!url && !file) throw new Error("attempted to download void");
- const pref = get(settings).save.savingMethod;
+ const pref = method ?? get(settings).save.savingMethod;
if (pref === "ask") {
- return openSavingDialog({ url, file, urlType });
+ return openSavingDialog({ url, file, urlType, copyTextURL });
}
/*
@@ -100,7 +111,8 @@ export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
url,
file,
body: get(t)("dialog.saving.timeout"),
- urlType
+ urlType,
+ copyTextURL,
});
}
@@ -134,10 +146,12 @@ export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
&& !(device.is.iOS && urlType === "redirect")) {
return openURL(url);
} else if (pref === "copy" && !file) {
- return copyURL(url);
+ return copyTextURL
+ ? await copyTextFromURL(copyTextURL)
+ : await copyURL(url);
}
}
} catch { /* catch & ignore */ }
- return openSavingDialog({ url, file, urlType });
+ return openSavingDialog({ url, file, urlType, copyTextURL });
}
diff --git a/web/src/lib/i18n/translations.ts b/web/src/lib/i18n/translations.ts
index 9a65c761..a48a2bc1 100644
--- a/web/src/lib/i18n/translations.ts
+++ b/web/src/lib/i18n/translations.ts
@@ -29,6 +29,7 @@ const config: Config<{
formats?: string;
limit?: number;
service?: string;
+ languages?: string[];
}> = {
fallbackLocale: defaultLocale,
translations: Object.keys(parsedLocfiles).reduce((obj, lang) => {
diff --git a/web/src/lib/settings/defaults.ts b/web/src/lib/settings/defaults.ts
index ce3114ec..9b58bfa5 100644
--- a/web/src/lib/settings/defaults.ts
+++ b/web/src/lib/settings/defaults.ts
@@ -3,7 +3,7 @@ import { defaultLocale } from "$lib/i18n/translations";
import type { CobaltSettings } from "$lib/types/settings";
const defaultSettings: CobaltSettings = {
- schemaVersion: 6,
+ schemaVersion: 7,
advanced: {
debug: false,
useWebCodecs: false,
@@ -28,6 +28,9 @@ const defaultSettings: CobaltSettings = {
audioFormat: "mp3",
disableMetadata: false,
downloadMode: "auto",
+ captionFormat: "txt",
+ captionLang: "none",
+ transcriptMethod: "download",
filenameStyle: "basic",
savingMethod: "download",
allowH265: false,
diff --git a/web/src/lib/settings/migrate.ts b/web/src/lib/settings/migrate.ts
index d033a6f9..8df26235 100644
--- a/web/src/lib/settings/migrate.ts
+++ b/web/src/lib/settings/migrate.ts
@@ -6,6 +6,7 @@ import type {
CobaltSettingsV4,
CobaltSettingsV5,
CobaltSettingsV6,
+ CobaltSettingsV7,
} from "$lib/types/settings";
import { getBrowserLanguage } from "$lib/settings/audio-sub-language";
@@ -95,6 +96,12 @@ const migrations: Record = {
return out as AllPartialSettingsWithSchema;
},
+
+ [7]: (settings: AllPartialSettingsWithSchema) => {
+ const out = settings as RecursivePartial;
+ out.schemaVersion = 7;
+ return out as AllPartialSettingsWithSchema;
+ },
};
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {
diff --git a/web/src/lib/settings/validate.ts b/web/src/lib/settings/validate.ts
index a711f9ae..3bf15a76 100644
--- a/web/src/lib/settings/validate.ts
+++ b/web/src/lib/settings/validate.ts
@@ -7,6 +7,8 @@ import {
themeOptions,
videoQualityOptions,
youtubeVideoCodecOptions,
+ captionFormatOptions,
+ transcriptMethodOptions,
type PartialSettings,
} from '$lib/types/settings';
import { youtubeDubLanguages } from '$lib/settings/audio-sub-language';
@@ -77,6 +79,8 @@ export function validateSettings(settings: PartialSettings) {
&& validateLiterals([
[ settings?.appearance?.theme , themeOptions ],
[ settings?.save?.downloadMode , downloadModeOptions ],
+ [ settings?.save?.captionFormat , captionFormatOptions ],
+ [ settings?.save?.transcriptMethod , transcriptMethodOptions ],
[ settings?.save?.filenameStyle , filenameStyleOptions ],
[ settings?.save?.videoQuality , videoQualityOptions ],
[ settings?.save?.youtubeVideoCodec, youtubeVideoCodecOptions ],
diff --git a/web/src/lib/types/api.ts b/web/src/lib/types/api.ts
index f127d02e..4729049a 100644
--- a/web/src/lib/types/api.ts
+++ b/web/src/lib/types/api.ts
@@ -15,6 +15,7 @@ export type CobaltErrorResponse = {
context?: {
service?: string,
limit?: number,
+ languages?: string[],
}
},
};
@@ -41,6 +42,7 @@ type CobaltRedirectResponse = {
type CobaltTunnelResponse = {
status: CobaltResponseType.Tunnel,
+ text?: string,
} & CobaltPartialURLResponse;
export const CobaltFileMetadataKeys = [
@@ -113,7 +115,7 @@ export type CobaltServerInfo = {
// this allows for extra properties, which is not ideal,
// but i couldn't figure out how to make a strict partial :(
export type CobaltSaveRequestBody =
- { url: string } & Partial>;
+ { url: string, captionLanguage?: string } & Partial>;
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
diff --git a/web/src/lib/types/dialog.ts b/web/src/lib/types/dialog.ts
index c0721f97..0110fb5b 100644
--- a/web/src/lib/types/dialog.ts
+++ b/web/src/lib/types/dialog.ts
@@ -46,6 +46,7 @@ type SavingDialog = Dialog & {
url?: string,
file?: File,
urlType?: CobaltFileUrlType,
+ copyTextURL?: string,
};
export type DialogInfo = SmallDialog | PickerDialog | SavingDialog;
diff --git a/web/src/lib/types/settings.ts b/web/src/lib/types/settings.ts
index 1d184eec..7cbfa85a 100644
--- a/web/src/lib/types/settings.ts
+++ b/web/src/lib/types/settings.ts
@@ -4,16 +4,18 @@ import type { CobaltSettingsV3 } from "$lib/types/settings/v3";
import type { CobaltSettingsV4 } from "$lib/types/settings/v4";
import type { CobaltSettingsV5 } from "$lib/types/settings/v5";
import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
+import type { CobaltSettingsV7 } from "$lib/types/settings/v7";
export * from "$lib/types/settings/v2";
export * from "$lib/types/settings/v3";
export * from "$lib/types/settings/v4";
export * from "$lib/types/settings/v5";
export * from "$lib/types/settings/v6";
+export * from "$lib/types/settings/v7";
-export type CobaltSettings = CobaltSettingsV6;
+export type CobaltSettings = CobaltSettingsV7;
-export type AnyCobaltSettings = CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
+export type AnyCobaltSettings = CobaltSettingsV6 | CobaltSettingsV5 | CobaltSettingsV4 | CobaltSettingsV3 | CobaltSettingsV2 | CobaltSettings;
export type PartialSettings = RecursivePartial;
diff --git a/web/src/lib/types/settings/v2.ts b/web/src/lib/types/settings/v2.ts
index bed04cf1..1a04ee47 100644
--- a/web/src/lib/types/settings/v2.ts
+++ b/web/src/lib/types/settings/v2.ts
@@ -3,7 +3,7 @@ import languages from "$i18n/languages.json";
export const themeOptions = ["auto", "light", "dark"] as const;
export const audioBitrateOptions = ["320", "256", "128", "96", "64", "8"] as const;
export const audioFormatOptions = ["best", "mp3", "ogg", "wav", "opus"] as const;
-export const downloadModeOptions = ["auto", "audio", "mute"] as const;
+export const downloadModeOptions = ["auto", "audio", "mute", "captions"] as const;
export const filenameStyleOptions = ["classic", "basic", "pretty", "nerdy"] as const;
export const videoQualityOptions = ["max", "2160", "1440", "1080", "720", "480", "360", "240", "144"] as const;
export const youtubeVideoCodecOptions = ["h264", "av1", "vp9"] as const;
diff --git a/web/src/lib/types/settings/v7.ts b/web/src/lib/types/settings/v7.ts
new file mode 100644
index 00000000..ccfc8bb9
--- /dev/null
+++ b/web/src/lib/types/settings/v7.ts
@@ -0,0 +1,14 @@
+import type { SubtitleLang } from "$lib/settings/audio-sub-language";
+import type { CobaltSettingsV6 } from "$lib/types/settings/v6";
+
+export const captionFormatOptions = ["txt", "vtt", "srt", "md"] as const;
+export const transcriptMethodOptions = ["ask", "download", "copy"] as const;
+
+export type CobaltSettingsV7 = Omit & {
+ schemaVersion: 7,
+ save: CobaltSettingsV6["save"] & {
+ captionFormat: typeof captionFormatOptions[number],
+ captionLang: SubtitleLang,
+ transcriptMethod: typeof transcriptMethodOptions[number],
+ },
+};
diff --git a/web/src/routes/settings/metadata/+page.svelte b/web/src/routes/settings/metadata/+page.svelte
index dfe02eef..c4f0321a 100644
--- a/web/src/routes/settings/metadata/+page.svelte
+++ b/web/src/routes/settings/metadata/+page.svelte
@@ -3,7 +3,12 @@
import { t } from "$lib/i18n/translations";
import { namedSubtitleLanguages } from "$lib/settings/audio-sub-language";
- import { filenameStyleOptions, savingMethodOptions } from "$lib/types/settings";
+ import {
+ captionFormatOptions,
+ filenameStyleOptions,
+ savingMethodOptions,
+ transcriptMethodOptions,
+ } from "$lib/types/settings";
import SettingsCategory from "$components/settings/SettingsCategory.svelte";
import Switcher from "$components/buttons/Switcher.svelte";
@@ -13,6 +18,12 @@
import SettingsDropdown from "$components/settings/SettingsDropdown.svelte";
const displayLangs = namedSubtitleLanguages($t);
+ const displayCaptionFormats = Object.fromEntries(
+ captionFormatOptions.map(format => [format, $t(`settings.captions.format.${format}`)])
+ );
+ const displayTranscriptMethods = Object.fromEntries(
+ transcriptMethodOptions.map(method => [method, $t(`settings.saving.${method}`)])
+ );
@@ -35,6 +46,39 @@
+
+
+
+
+
+
{#each savingMethodOptions as value}