web: add transcript download controls

This commit is contained in:
lmdev 2026-09-03 22:39:13 -04:00
parent d63deb94ac
commit d523748431
19 changed files with 170 additions and 20 deletions

View file

@ -7,6 +7,7 @@
"download": "download",
"share": "share",
"copy": "copy",
"copy.text": "copy text",
"copy.section": "copy the section link",
"copied": "copied",
"import": "import",

View file

@ -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",

View file

@ -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 }}."
}

View file

@ -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",

View file

@ -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",

View file

@ -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") : ""}
>
<CopyIcon check={copied} />
{$t("button.copy")}
<CopyIcon check={copied} regularIcon={!!copyTextURL} />
{$t(copyTextURL ? "button.copy.text" : "button.copy")}
</VerticalActionButton>
{/if}
</div>
@ -125,6 +137,12 @@
{bodyText}
</div>
{/if}
{#if copyFailed}
<div class="body-text">
{$t("dialog.saving.copy_failed")}
</div>
{/if}
</div>
<DialogButtons

View file

@ -32,6 +32,7 @@
import IconMusic from "$components/icons/Music.svelte";
import IconSparkles from "$components/icons/Sparkles.svelte";
import IconClipboard from "$components/icons/Clipboard.svelte";
import IconFileText from "@tabler/icons-svelte/IconFileText.svelte";
let linkInput: Optional<HTMLInputElement>;
@ -223,6 +224,14 @@
<IconMute />
{$t("save.mute")}
</SettingsButton>
<SettingsButton
settingContext="save"
settingId="downloadMode"
settingValue="captions"
>
<IconFileText />
{$t("save.captions")}
</SettingsButton>
</Switcher>
<ActionButton id="paste" click={pasteClipboard}>

View file

@ -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");

View file

@ -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 });
}

View file

@ -29,6 +29,7 @@ const config: Config<{
formats?: string;
limit?: number;
service?: string;
languages?: string[];
}> = {
fallbackLocale: defaultLocale,
translations: Object.keys(parsedLocfiles).reduce((obj, lang) => {

View file

@ -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,

View file

@ -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<number, Migrator> = {
return out as AllPartialSettingsWithSchema;
},
[7]: (settings: AllPartialSettingsWithSchema) => {
const out = settings as RecursivePartial<CobaltSettingsV7>;
out.schemaVersion = 7;
return out as AllPartialSettingsWithSchema;
},
};
export const migrate = (settings: AllPartialSettingsWithSchema): PartialSettings => {

View file

@ -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 ],

View file

@ -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<Omit<CobaltSettings['save'], 'savingMethod'>>;
{ url: string, captionLanguage?: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;

View file

@ -46,6 +46,7 @@ type SavingDialog = Dialog & {
url?: string,
file?: File,
urlType?: CobaltFileUrlType,
copyTextURL?: string,
};
export type DialogInfo = SmallDialog | PickerDialog | SavingDialog;

View file

@ -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<CobaltSettings>;

View file

@ -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;

View file

@ -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<CobaltSettingsV6, "schemaVersion" | "save"> & {
schemaVersion: 7,
save: CobaltSettingsV6["save"] & {
captionFormat: typeof captionFormatOptions[number],
captionLang: SubtitleLang,
transcriptMethod: typeof transcriptMethodOptions[number],
},
};

View file

@ -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}`)])
);
</script>
<SettingsCategory sectionId="filename" title={$t("settings.metadata.filename")}>
@ -35,6 +46,39 @@
</div>
</SettingsCategory>
<SettingsCategory
sectionId="transcripts"
title={$t("settings.captions")}
>
<SettingsDropdown
title={$t("settings.captions.format")}
description={$t("settings.captions.format.description")}
items={displayCaptionFormats}
settingContext="save"
settingId="captionFormat"
selectedOption={$settings.save.captionFormat}
selectedTitle={displayCaptionFormats[$settings.save.captionFormat]}
/>
<SettingsDropdown
title={$t("settings.captions.method")}
description={$t("settings.captions.method.description")}
items={displayTranscriptMethods}
settingContext="save"
settingId="transcriptMethod"
selectedOption={$settings.save.transcriptMethod}
selectedTitle={displayTranscriptMethods[$settings.save.transcriptMethod]}
/>
<SettingsDropdown
title={$t("settings.captions.language")}
description={$t("settings.captions.language.description")}
items={displayLangs}
settingContext="save"
settingId="captionLang"
selectedOption={$settings.save.captionLang}
selectedTitle={displayLangs[$settings.save.captionLang]}
/>
</SettingsCategory>
<SettingsCategory sectionId="saving" title={$t("settings.saving.title")}>
<Switcher big={true} description={$t("settings.saving.description")}>
{#each savingMethodOptions as value}