From 609ee6cd3c79e8b5ad9c75fed40b57cfd650a307 Mon Sep 17 00:00:00 2001 From: lmdev <138343225+lmdevv@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:32:38 -0400 Subject: [PATCH] api/loom: add transcript-only downloads --- api/README.md | 4 +- api/src/processing/helpers/captions.js | 52 ++++++++++++++++---------- api/src/processing/match.js | 3 ++ api/src/processing/service-config.js | 2 +- api/src/processing/services/loom.js | 38 +++++++++++++++++-- api/src/util/tests/loom.json | 12 ++++++ 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/api/README.md b/api/README.md index 4bc2caf6..3fb049af 100644 --- a/api/README.md +++ b/api/README.md @@ -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 | diff --git a/api/src/processing/helpers/captions.js b/api/src/processing/helpers/captions.js index 7109bcac..94edef87 100644 --- a/api/src/processing/helpers/captions.js +++ b/api/src/processing/helpers/captions.js @@ -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"); diff --git a/api/src/processing/match.js b/api/src/processing/match.js index c3373cde..481986c5 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -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; diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index fbcc26d2..f5206ad7 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -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 = { diff --git a/api/src/processing/services/loom.js b/api/src/processing/services/loom.js index 64382108..47272a17 100644 --- a/api/src/processing/services/loom.js +++ b/api/src/processing/services/loom.js @@ -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 { diff --git a/api/src/util/tests/loom.json b/api/src/util/tests/loom.json index 0ebb4580..4dd38479 100644 --- a/api/src/util/tests/loom.json +++ b/api/src/util/tests/loom.json @@ -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",