Skill Tracer
Skills › HyperFrames › media-use › openverse.mjs

openverse.mjs

134 lines · skills/media-use/audio/scripts/lib/openverse.mjs at a595755

1// Sound effects from Openverse (https://api.openverse.org): free, no key.
2// Anonymous use is rate limited (20 requests a minute, 200 a day); callers fall
3// back to the bundled library when it is unavailable.
4
5import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6import { dirname } from "node:path";
7
8const OPENVERSE_AUDIO_URL = "https://api.openverse.org/v1/audio/";
9// The only licences media-use accepts: no NonCommercial, ShareAlike or NoDerivatives.
10export const OPENVERSE_SFX_LICENSES = new Set(["cc0", "by"]);
11const PLAYABLE_FILETYPES = new Set(["mp3", "wav", "ogg", "flac"]);
12const MAX_SFX_MS = 15000;
13const IDEAL_SFX_MS = 4000;
14const BY_PENALTY = 2;
15const SEARCH_TIMEOUT_MS = 10000;
17// Openverse text lands in markdown and JSON: one line, no control or markdown-structure characters, bounded.
18const oneLine = (text) =>
19 String(text ?? "")
20 .replace(/[\p{Cc}`[\]<>\\]+/gu, " ")
21 .trim()
22 .slice(0, 300);
23// Openverse's audio providers (api.openverse.org/v1/audio/stats/); a new provider's link is dropped until listed.
24const SOURCE_HOSTS = new Set(["freesound.org", "www.jamendo.com", "commons.wikimedia.org"]);
25function httpsUrl(url, hosts) {
26 try {
27 const parsed = new URL(url);
28 return parsed.protocol === "https:" && (!hosts || hosts.has(parsed.hostname))
29 ? oneLine(parsed.href)
30 : "";
31 } catch {
32 return "";
33 }
36/** Openverse could not answer (offline, rate limited, server error): stop asking it this run. */
37export class OpenverseUnavailableError extends Error {
38 constructor(message) {
39 super(message);
40 this.name = "OpenverseUnavailableError";
41 }
44function usable(result) {
45 return (
46 OPENVERSE_SFX_LICENSES.has(result?.license) &&
47 PLAYABLE_FILETYPES.has(result.filetype) &&
48 typeof result.url === "string" &&
49 result.url.startsWith("https://") &&
50 Number.isFinite(result.duration) &&
51 result.duration > 0 &&
52 result.duration <= MAX_SFX_MS
53 );
56// Lower is better: relevance position, then seconds past a typical cue length, then CC0 over CC BY.
57function penalty(result, position) {
58 const longBy = Math.max(0, result.duration - IDEAL_SFX_MS) / 1000;
59 return position + longBy + (result.license === "by" ? BY_PENALTY : 0);
62/** Best usable sound in Openverse's relevance-ordered results, or null. */
63export function pickOpenverseSfx(results) {
64 let best = null;
65 results.forEach((result, position) => {
66 if (!usable(result)) return;
67 const score = penalty(result, position);
68 if (!best || score < best.score) best = { result, score };
69 });
70 if (!best) return null;
71 const r = best.result;
72 return {
73 id: r.id,
74 url: r.url,
75 ext: `.${r.filetype}`,
76 title: oneLine(r.title),
77 creator: oneLine(r.creator),
78 license: r.license,
79 licenseUrl: httpsUrl(r.license_url),
80 sourceUrl: httpsUrl(r.foreign_landing_url, SOURCE_HOSTS),
81 credit: oneLine(r.attribution),
82 durationS: Math.round(r.duration) / 1000,
83 };
86/** Best licensed sound effect for `query`, null on no match; throws OpenverseUnavailableError. */
87export async function searchOpenverseSfx(query, { fetchImpl = fetch } = {}) {
88 const params = new URLSearchParams({
89 q: query,
90 page_size: "20",
91 license: [...OPENVERSE_SFX_LICENSES].join(","),
92 });
93 let response;
94 try {
95 response = await fetchImpl(`${OPENVERSE_AUDIO_URL}?${params}`, {
96 headers: { Accept: "application/json" },
97 signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
98 });
99 } catch (error) {
100 throw new OpenverseUnavailableError(`Openverse unreachable (${error.message})`);
101 }
102 if (response.status === 429)
103 throw new OpenverseUnavailableError("Openverse rate limit reached (HTTP 429)");
104 if (!response.ok) throw new OpenverseUnavailableError(`Openverse HTTP ${response.status}`);
105 let body;
106 try {
107 body = await response.json();
108 } catch {
109 throw new OpenverseUnavailableError("Openverse returned non-JSON");
110 }
111 return pickOpenverseSfx(Array.isArray(body?.results) ? body.results : []);
114/** Record a CC BY sound's credit for `file` in the project's credits file (one line per file). */
115export function writeSfxCredit(creditsPath, file, sound) {
116 if (sound.license !== "by") return;
117 // The sound may come from a saved record, not this run's pick: clean it again here.
118 const sourceUrl = httpsUrl(sound.sourceUrl, SOURCE_HOSTS);
119 const source = sourceUrl ? ` Source: ${sourceUrl}` : "";
120 const line = `- \`${file}\`: ${oneLine(sound.credit)}${source}`;
121 let prior = "";
122 try {
123 prior = readFileSync(creditsPath, "utf8");
124 } catch (error) {
125 if (error.code !== "ENOENT") throw error;
126 }
127 const kept = prior
128 .split("\n")
129 .filter((l) => l.startsWith("- ") && !l.startsWith(`- \`${file}\`:`));
130 const header =
131 "# Sound effect credits\n\nThese sounds are licensed CC BY: credit them wherever the video is published.\n\n";
132 mkdirSync(dirname(creditsPath), { recursive: true });
133 writeFileSync(creditsPath, `${header}${[...kept, line].join("\n")}\n`);