api/loom: add transcript-only downloads

This commit is contained in:
lmdev 2026-09-04 00:32:38 -04:00
parent 2d630d11a5
commit 609ee6cd3c
6 changed files with 84 additions and 27 deletions

View file

@ -22,7 +22,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
| dailymotion | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| instagram | ✅ | ✅ | ✅ | ❌ | ➖ | ➖ |
| facebook | ✅ | ❌ | ✅ | ❌ | ➖ | ➖ |
| loom | ✅ | ❌ | ✅ | ❌ | ✅ | ➖ |
| loom | ✅ | ❌ | ✅ | ✅ | ✅ | ➖ |
| newgrounds | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| ok.ru | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
| pinterest | ✅ | ✅ | ✅ | ❌ | ➖ | ➖ |
@ -45,7 +45,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
| ➖ | unreasonable/impossible |
| ❌ | not supported |
standalone transcript downloads are supported for youtube with `downloadMode: "captions"`.
standalone transcript downloads are supported for marked services with `downloadMode: "captions"`.
### additional notes or features (per service)
| service | notes or features |

View file

@ -1,5 +1,14 @@
const normalizedLanguage = language => language?.toLowerCase().replace("_", "-");
export const matchesCaptionLanguage = (language, requested) => {
if (!requested || !language) return true;
const normalized = normalizedLanguage(language);
const normalizedRequested = normalizedLanguage(requested);
return normalized === normalizedRequested
|| normalized?.split("-")[0] === normalizedRequested?.split("-")[0];
};
export const createCaptionResponse = ({
url,
format,
@ -10,26 +19,29 @@ export const createCaptionResponse = ({
author,
source,
headers,
}) => ({
type: "captions",
isCaptionOnly: true,
urls: url,
headers,
captionFormat: format,
captionLanguage: language,
filenameAttributes: {
service,
id,
title: title || `${service}_${id}`,
author,
},
captionMetadata: {
title,
author,
language,
source,
},
});
}) => {
const resolvedTitle = title || `${service}_${id}`;
return {
type: "captions",
isCaptionOnly: true,
urls: url,
headers,
captionFormat: format,
captionLanguage: language || "unknown",
filenameAttributes: {
service,
id,
title: resolvedTitle,
author,
},
captionMetadata: {
title: resolvedTitle,
author,
language,
source,
},
};
};
export const selectCaptionTrack = (tracks, subtitleLang, allowAutomatic = false) => {
const allowed = tracks.filter(track => allowAutomatic || track.kind !== "asr");

View file

@ -252,6 +252,9 @@ export default async function({ host, patternMatch, params, authType }) {
r = await loom({
id: patternMatch.id,
subtitleLang,
isCaptionOnly,
captionLanguage: params.captionLanguage || subtitleLang,
captionFormat: params.captionFormat,
});
break;

View file

@ -1,7 +1,7 @@
import UrlPattern from "url-pattern";
export const audioIgnore = new Set(["vk", "ok", "loom"]);
export const captionOnlyServices = new Set(["youtube"]);
export const captionOnlyServices = new Set(["loom", "youtube"]);
export const hlsExceptions = new Set(["dailymotion", "vimeo", "rutube", "bsky", "youtube"]);
export const services = {

View file

@ -1,4 +1,8 @@
import { genericUserAgent } from "../../config.js";
import {
createCaptionResponse,
matchesCaptionLanguage,
} from "../helpers/captions.js";
const craftHeaders = id => ({
"user-agent": genericUserAgent,
@ -80,12 +84,38 @@ async function getTranscript(id) {
.then(r => r.status === 200 && r.json())
.catch(() => {});
if (gql?.data?.fetchVideoTranscript?.captions_source_url?.includes('.vtt?')) {
return gql.data.fetchVideoTranscript.captions_source_url;
const transcript = gql?.data?.fetchVideoTranscript;
if (transcript?.captions_source_url?.includes('.vtt?')) {
return {
url: transcript.captions_source_url,
language: transcript.language,
};
}
}
export default async function({ id, subtitleLang }) {
export default async function({
id,
subtitleLang,
isCaptionOnly,
captionLanguage,
captionFormat,
}) {
if (isCaptionOnly) {
const transcript = await getTranscript(id);
if (!transcript || !matchesCaptionLanguage(transcript.language, captionLanguage)) {
return { error: "fetch.empty" };
}
return createCaptionResponse({
url: transcript.url,
format: captionFormat,
language: transcript.language,
service: "loom",
id,
source: `https://www.loom.com/share/${id}`,
});
}
let url = await fromTranscodedURL(id);
url ??= await fromRawURL(id);
@ -96,7 +126,7 @@ export default async function({ id, subtitleLang }) {
let subtitles;
if (subtitleLang) {
const transcript = await getTranscript(id);
if (transcript) subtitles = transcript;
if (transcript) subtitles = transcript.url;
}
return {

View file

@ -30,6 +30,18 @@
"status": "error"
}
},
{
"name": "standalone transcript",
"url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9",
"params": {
"downloadMode": "captions",
"captionFormat": "txt"
},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "video with no transcodedUrl",
"url": "https://www.loom.com/share/aa3d8b08bee74d05af5b42989e9f33e9",