@postfetch/core 0.12.5 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "spotify" | "tiktok" | "twitter" | "youtube";
3
3
  /** The kind of a {@link MediaItem}. */
4
4
  export type MediaKind = "audio" | "image" | "video";
5
5
  /** A single resolved media file: a direct URL plus everything needed to fetch and name it. */
@@ -144,6 +144,17 @@ export type SoundcloudExtra = {
144
144
  /** Track duration in seconds. */
145
145
  durationSeconds?: number;
146
146
  };
147
+ /** Spotify track metadata, with the YouTube source selected for downloading. */
148
+ export type SpotifyExtra = {
149
+ /** Artists credited on Spotify, in order. */
150
+ artists: string[];
151
+ /** Spotify track duration in seconds, when available. */
152
+ durationSeconds?: number;
153
+ /** Public watch URL passed to the existing YouTube resolver. */
154
+ youtubeUrl: string;
155
+ /** A live performance was selected after original-recording searches failed. */
156
+ youtubeLiveFallback?: boolean;
157
+ };
147
158
  /** Pinterest-specific metadata. */
148
159
  export type PinterestExtra = {
149
160
  /** Times the pin was saved (repinned). */
@@ -165,7 +176,7 @@ export type PostComment = {
165
176
  items: MediaItem[];
166
177
  };
167
178
  /** The result of resolving a post: its platform, id, media items and metadata. */
168
- export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
179
+ export type PostfetchResult = PlatformResult<"facebook", never> | PlatformResult<"instagram", InstagramExtra> | PlatformResult<"linkedin", never> | PlatformResult<"pinterest", PinterestExtra> | PlatformResult<"reddit", RedditExtra> | PlatformResult<"soundcloud", SoundcloudExtra> | PlatformResult<"spotify", SpotifyExtra> | PlatformResult<"tiktok", TiktokExtra> | PlatformResult<"twitter", TwitterExtra> | PlatformResult<"youtube", YoutubeExtra>;
169
180
  /** One platform's variant of a {@link PostfetchResult}, carrying that platform's `extra` metadata shape. */
170
181
  export type PlatformResult<P extends Platform, Extra> = {
171
182
  /** Suggested filename when zipping every item. */
@@ -198,7 +209,7 @@ export type ResolveContext = {
198
209
  * Lets callers tell a user-facing limitation (age-gated, private, removed) apart
199
210
  * from a transient/technical failure.
200
211
  */
201
- export type PostfetchReason = "ageRestricted" | "private" | "loginRequired" | "deleted" | "notFound" | "rateLimited" | "unavailable";
212
+ export type PostfetchReason = "ageRestricted" | "private" | "loginRequired" | "deleted" | "notFound" | "noMatch" | "rateLimited" | "unavailable";
202
213
  /** An error carrying an HTTP status, so adapters can map failures onto responses. */
203
214
  export declare class PostfetchError extends Error {
204
215
  /** HTTP status that best describes the failure. */
@@ -220,6 +231,8 @@ export declare class PostfetchError extends Error {
220
231
  }
221
232
  export type Json = Record<string, unknown>;
222
233
  export declare function createNet(baseFetch?: typeof fetch): Net;
234
+ /** Retry interrupted body reads; request/status retries remain owned by net. */
235
+ export declare function readBody<T>(net: Net, url: string, init: RequestInit, read: (response: Response) => Promise<T>): Promise<T>;
223
236
  export declare function object(value: unknown): value is Json;
224
237
  export declare function string(value: unknown): string | null;
225
238
  export declare function number(value: unknown): number | null;
@@ -0,0 +1,3 @@
1
+ import { type PlatformResult, type ResolveContext, type SpotifyExtra } from "./internal";
2
+ /** Resolve Spotify track metadata and a matching video through YouTube. */
3
+ export declare function resolveSpotify(input: ResolveContext): Promise<PlatformResult<"spotify", SpotifyExtra>>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.12.5",
4
- "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
3
+ "version": "0.13.1",
4
+ "description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube post URLs into media files.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
@@ -35,6 +35,7 @@
35
35
  "pinterest",
36
36
  "reddit",
37
37
  "soundcloud",
38
+ "spotify",
38
39
  "tiktok",
39
40
  "twitter",
40
41
  "youtube",
package/src/download.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { assembleHls } from "./hls";
2
- import { createNet, PostfetchError, type MediaItem, type Net, type PostfetchResult } from "./internal";
2
+ import { createNet, PostfetchError, readBody, type MediaItem, type Net, type PostfetchResult } from "./internal";
3
3
  import { prepareMp4 } from "./mp4-remux";
4
4
  import { mergeAudioVideo } from "./remux";
5
5
  import { zip } from "./zip";
@@ -118,13 +118,16 @@ export async function downloadBlob(
118
118
  const options = modern ? optionsOrHeaders : legacyOptions;
119
119
  const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
120
120
  const net = createNet(options.fetch ?? globalThis.fetch);
121
- const response = typeof input === "string"
122
- ? await net(input, { headers })
123
- : await download({ ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers }, options);
124
- if (!response.ok || !response.body) {
125
- throw new PostfetchError(502, `download failed: ${response.status}`);
126
- }
127
- const blob = await response.blob();
121
+ const item = typeof input === "string" ? null
122
+ : { ...input, headers: modern ? optionsOrHeaders.headers ?? input.headers : input.headers };
123
+ const blob = item && buffered(item)
124
+ ? new Blob([toArrayBuffer(await itemBytes(net, item))], { type: item.mime })
125
+ : await readBody(net, typeof input === "string" ? input : input.url, { headers: item?.headers ?? headers }, async (response) => {
126
+ if (!response.ok || !response.body) {
127
+ throw new PostfetchError(502, `download failed: ${response.status}`);
128
+ }
129
+ return response.blob();
130
+ });
128
131
  if (!modern || optionsOrHeaders.remux !== true) {
129
132
  return blob;
130
133
  }
@@ -185,10 +188,16 @@ export async function archive(result: PostfetchResult, options: DownloadOptions
185
188
  * @returns A `Response` a server or edge function can return directly.
186
189
  */
187
190
  export async function toResponse(result: PostfetchResult, options: DownloadOptions = {}): Promise<Response> {
188
- if (result.items.length === 1) {
189
- return singleResponse(result.items[0], options);
191
+ const response = result.items.length === 1
192
+ ? await singleResponse(result.items[0], options)
193
+ : await archiveResponse(result, options);
194
+ if (result.platform === "spotify") {
195
+ response.headers.set("x-media-platform", result.platform);
196
+ response.headers.set("x-media-id", result.id);
197
+ const youtubeUrl = result.metadata?.extra?.youtubeUrl;
198
+ if (youtubeUrl) response.headers.set("x-media-youtube-url", youtubeUrl);
190
199
  }
191
- return archiveResponse(result, options);
200
+ return response;
192
201
  }
193
202
 
194
203
  async function singleResponse(item: MediaItem, options: DownloadOptions): Promise<Response> {
@@ -261,11 +270,12 @@ async function mergedBytes(net: Net, item: MediaItem, audio: NonNullable<MediaIt
261
270
  }
262
271
 
263
272
  async function fetchBytes(net: Net, url: string, headers: HeadersInit): Promise<Uint8Array> {
264
- const response = await net(url, { headers });
265
- if (!response.ok) {
266
- throw new PostfetchError(502, `download failed: ${response.status}`);
267
- }
268
- return new Uint8Array(await response.arrayBuffer());
273
+ return readBody(net, url, { headers }, async (response) => {
274
+ if (!response.ok) {
275
+ throw new PostfetchError(502, `download failed: ${response.status}`);
276
+ }
277
+ return new Uint8Array(await response.arrayBuffer());
278
+ });
269
279
  }
270
280
 
271
281
  function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
package/src/hls.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { concat } from "./isobmff";
2
- import type { Net } from "./internal";
2
+ import { readBody, type Net } from "./internal";
3
3
  import { packedAacToMp4 } from "./packed-aac";
4
4
 
5
5
  // HLS support for the fMP4/CMAF playlists reddit-style muxing already covers:
@@ -54,11 +54,13 @@ export function parseMaster(text: string, baseUrl: string, options: { preferOrig
54
54
  }
55
55
 
56
56
  export async function assembleHls(net: Net, playlistUrl: string, headers: HeadersInit): Promise<Uint8Array> {
57
- const response = await net(playlistUrl, { headers });
58
- if (!response.ok) {
59
- throw new Error(`HLS playlist failed: ${response.status}`);
60
- }
61
- const segments = mediaSegments(await response.text(), playlistUrl);
57
+ const text = await readBody(net, playlistUrl, { headers }, async (response) => {
58
+ if (!response.ok) {
59
+ throw new Error(`HLS playlist failed: ${response.status}`);
60
+ }
61
+ return response.text();
62
+ });
63
+ const segments = mediaSegments(text, playlistUrl);
62
64
  if (segments.length === 0) {
63
65
  throw new Error("HLS playlist has no segments");
64
66
  }
@@ -122,9 +124,10 @@ async function fetchSegment(net: Net, segment: Segment, headers: HeadersInit): P
122
124
  if (segment.range) {
123
125
  requestHeaders.set("range", `bytes=${segment.range.offset}-${segment.range.offset + segment.range.length - 1}`);
124
126
  }
125
- const response = await net(segment.url, { headers: requestHeaders });
126
- if (!response.ok) {
127
- throw new Error(`HLS segment failed: ${response.status}`);
128
- }
129
- return new Uint8Array(await response.arrayBuffer());
127
+ return readBody(net, segment.url, { headers: requestHeaders }, async (response) => {
128
+ if (!response.ok) {
129
+ throw new Error(`HLS segment failed: ${response.status}`);
130
+ }
131
+ return new Uint8Array(await response.arrayBuffer());
132
+ });
130
133
  }
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Turn a public social post URL into its media.
3
3
  *
4
- * {@link postfetch} resolves Instagram, LinkedIn, TikTok, YouTube, Facebook and X (Twitter)
5
- * URLs to typed {@link MediaItem}s. Zero dependencies, only Web-standard APIs
4
+ * {@link postfetch} resolves public social post and Spotify track URLs to typed
5
+ * {@link MediaItem}s. Spotify tracks are matched to YouTube videos.
6
+ * Zero dependencies, only Web-standard APIs
6
7
  * (`fetch`, `Response`, `URL`, `crypto`) — so it runs on Deno, Node, Bun,
7
8
  * Cloudflare Workers and browsers alike. It only resolves the media; the
8
9
  * {@link download}, {@link downloadBlob}, {@link archive} and {@link toResponse}
@@ -46,6 +47,7 @@ export type {
46
47
  PostMetadata,
47
48
  RedditExtra,
48
49
  SoundcloudExtra,
50
+ SpotifyExtra,
49
51
  TiktokExtra,
50
52
  TwitterExtra,
51
53
  TwitterQuotedTweet,
package/src/internal.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** A platform that {@link postfetch} can resolve. */
2
- export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "tiktok" | "twitter" | "youtube";
2
+ export type Platform = "facebook" | "instagram" | "linkedin" | "pinterest" | "reddit" | "soundcloud" | "spotify" | "tiktok" | "twitter" | "youtube";
3
3
 
4
4
  /** The kind of a {@link MediaItem}. */
5
5
  export type MediaKind = "audio" | "image" | "video";
@@ -151,6 +151,18 @@ export type SoundcloudExtra = {
151
151
  durationSeconds?: number;
152
152
  };
153
153
 
154
+ /** Spotify track metadata, with the YouTube source selected for downloading. */
155
+ export type SpotifyExtra = {
156
+ /** Artists credited on Spotify, in order. */
157
+ artists: string[];
158
+ /** Spotify track duration in seconds, when available. */
159
+ durationSeconds?: number;
160
+ /** Public watch URL passed to the existing YouTube resolver. */
161
+ youtubeUrl: string;
162
+ /** A live performance was selected after original-recording searches failed. */
163
+ youtubeLiveFallback?: boolean;
164
+ };
165
+
154
166
  /** Pinterest-specific metadata. */
155
167
  export type PinterestExtra = {
156
168
  /** Times the pin was saved (repinned). */
@@ -181,6 +193,7 @@ export type PostfetchResult =
181
193
  | PlatformResult<"pinterest", PinterestExtra>
182
194
  | PlatformResult<"reddit", RedditExtra>
183
195
  | PlatformResult<"soundcloud", SoundcloudExtra>
196
+ | PlatformResult<"spotify", SpotifyExtra>
184
197
  | PlatformResult<"tiktok", TiktokExtra>
185
198
  | PlatformResult<"twitter", TwitterExtra>
186
199
  | PlatformResult<"youtube", YoutubeExtra>;
@@ -226,6 +239,7 @@ export type PostfetchReason =
226
239
  | "loginRequired"
227
240
  | "deleted"
228
241
  | "notFound"
242
+ | "noMatch"
229
243
  | "rateLimited"
230
244
  | "unavailable";
231
245
 
@@ -278,6 +292,27 @@ export function createNet(baseFetch: typeof fetch = globalThis.fetch): Net {
278
292
  };
279
293
  }
280
294
 
295
+ /** Retry interrupted body reads; request/status retries remain owned by net. */
296
+ export async function readBody<T>(
297
+ net: Net,
298
+ url: string,
299
+ init: RequestInit,
300
+ read: (response: Response) => Promise<T>,
301
+ ): Promise<T> {
302
+ for (let attempt = 1; ; attempt += 1) {
303
+ const response = await net(url, init);
304
+ // Let callers report HTTP/empty-body failures without restarting request retries.
305
+ if (!response.ok || !response.body) return read(response);
306
+ try {
307
+ return await read(response);
308
+ } catch (error) {
309
+ if (attempt === 3) throw error;
310
+ await response.body?.cancel().catch(() => {});
311
+ await sleep(500 * 2 ** (attempt - 1));
312
+ }
313
+ }
314
+ }
315
+
281
316
  export function object(value: unknown): value is Json {
282
317
  return typeof value === "object" && value !== null && !Array.isArray(value);
283
318
  }
package/src/postfetch.ts CHANGED
@@ -5,6 +5,7 @@ import { resolveLinkedin } from "./linkedin";
5
5
  import { resolvePinterest } from "./pinterest";
6
6
  import { resolveReddit } from "./reddit";
7
7
  import { resolveSoundcloud } from "./soundcloud";
8
+ import { resolveSpotify } from "./spotify";
8
9
  import { resolveTiktok } from "./tiktok";
9
10
  import { resolveTwitter } from "./twitter";
10
11
  import { resolveYoutube } from "./youtube";
@@ -85,6 +86,8 @@ async function resolve(context: ResolveContext): Promise<PostfetchResult> {
85
86
  return resolveReddit(context);
86
87
  case "soundcloud":
87
88
  return resolveSoundcloud(context);
89
+ case "spotify":
90
+ return resolveSpotify(context);
88
91
  case "tiktok":
89
92
  return resolveTiktok(context);
90
93
  case "twitter":
@@ -180,6 +183,9 @@ export function detect(url: string): Platform {
180
183
  if (host.includes("soundcloud.com") || host === "snd.sc") {
181
184
  return "soundcloud";
182
185
  }
186
+ if (host === "open.spotify.com") {
187
+ return "spotify";
188
+ }
183
189
  if (host.includes("youtube.com") || host === "youtu.be") {
184
190
  return "youtube";
185
191
  }
@@ -189,5 +195,5 @@ export function detect(url: string): Platform {
189
195
  if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
190
196
  return "twitter";
191
197
  }
192
- throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
198
+ throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, Spotify, TikTok, X and YouTube URLs are supported");
193
199
  }
package/src/spotify.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { browserUserAgent } from "./fingerprint";
2
+ import { asUrl, filename, number, object, PostfetchError, string, type Json, type PlatformResult, type PostfetchResult, type ResolveContext, type SpotifyExtra } from "./internal";
3
+ import { resolveYoutube } from "./youtube";
4
+
5
+ type Track = { title: string; artists: string[]; durationSeconds?: number };
6
+
7
+ /** Resolve Spotify track metadata and a matching video through YouTube. */
8
+ export async function resolveSpotify(input: ResolveContext): Promise<PlatformResult<"spotify", SpotifyExtra>> {
9
+ const url = asUrl(input.url);
10
+ const id = url.pathname.match(/^\/(?:intl-[a-z-]+\/)?(?:embed\/)?track\/([A-Za-z0-9]{22})\/?$/i)?.[1];
11
+ if (!id) {
12
+ throw new PostfetchError(400, "Spotify requires an open.spotify.com track URL");
13
+ }
14
+ const track = await spotifyTrack(input, id);
15
+ const { youtube, youtubeUrl, liveFallback } = await matchingYoutube(input, track);
16
+ return {
17
+ archiveFilename: filename(`spotify_${id}.zip`),
18
+ comments: [],
19
+ id,
20
+ items: youtube.items,
21
+ platform: "spotify",
22
+ metadata: {
23
+ title: track.title,
24
+ author: { name: track.artists.join(", ") },
25
+ extra: {
26
+ artists: track.artists, durationSeconds: track.durationSeconds, youtubeUrl,
27
+ ...(liveFallback ? { youtubeLiveFallback: true } : {}),
28
+ },
29
+ },
30
+ };
31
+ }
32
+
33
+ async function spotifyTrack(input: ResolveContext, id: string): Promise<Track> {
34
+ const response = await input.net(`https://open.spotify.com/embed/track/${id}`, {
35
+ headers: { "user-agent": browserUserAgent() },
36
+ });
37
+ if (!response.ok) {
38
+ throw new PostfetchError(response.status === 404 ? 404 : 502, `Spotify track lookup failed: ${response.status}`);
39
+ }
40
+ const html = await response.text();
41
+ const raw = html.match(/<script\b[^>]*\bid=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)?.[1];
42
+ const payload = parseJson(raw);
43
+ const props = payload && object(payload.props) ? payload.props : null;
44
+ const page = props && object(props.pageProps) ? props.pageProps : null;
45
+ const state = page && object(page.state) ? page.state : null;
46
+ const data = state && object(state.data) ? state.data : null;
47
+ const entity = data && object(data.entity) ? data.entity : null;
48
+ const title = entity && (string(entity.title) ?? string(entity.name));
49
+ const artists = entity && Array.isArray(entity.artists)
50
+ ? entity.artists.filter(object).map((artist) => string(artist.name)).filter((name): name is string => name !== null)
51
+ : [];
52
+ if (!entity || entity.type !== "track" || entity.id !== id || !title || artists.length === 0) {
53
+ throw new PostfetchError(502, "Spotify track metadata not found");
54
+ }
55
+ const duration = number(entity.duration);
56
+ return { title, artists, durationSeconds: duration !== null && duration > 0 ? duration / 1000 : undefined };
57
+ }
58
+
59
+ async function matchingYoutube(input: ResolveContext, track: Track): Promise<{ youtube: PostfetchResult; youtubeUrl: string; liveFallback: boolean }> {
60
+ const quotedTitle = `"${track.title.replace(/"/g, "")}"`;
61
+ const primaryQuery = `${track.artists.join(" ")} ${track.title} audio`;
62
+ const queries = new Set([
63
+ primaryQuery,
64
+ `${track.artists[0]} ${quotedTitle}`,
65
+ track.title,
66
+ `${track.artists[0]} ${track.title.replace(/\s+/g, "")}`,
67
+ ]);
68
+ const attempted = new Set<string>();
69
+ const live = new Map<string, number>();
70
+ let resolutionError: unknown;
71
+ let searchError: unknown;
72
+ for (const query of queries) {
73
+ let candidates: Json[];
74
+ try {
75
+ candidates = await searchYoutube(input, query);
76
+ } catch (error) {
77
+ // A failed primary search is a technical failure, not a missing match.
78
+ if (query === primaryQuery) throw error;
79
+ searchError = error;
80
+ continue;
81
+ }
82
+ const originals = candidates.map((candidate, index) => ({
83
+ candidate, score: matchScore(track, candidate) - index / 1000,
84
+ })).filter(({ score }) => Number.isFinite(score)).sort((left, right) => right.score - left.score);
85
+ for (const candidate of candidates) {
86
+ if (Number.isFinite(matchScore(track, candidate))) continue;
87
+ const score = matchScore(track, candidate, true);
88
+ if (Number.isFinite(score)) {
89
+ const url = `https://www.youtube.com/watch?v=${candidate.videoId}`;
90
+ live.set(url, Math.max(live.get(url) ?? -Infinity, score));
91
+ }
92
+ }
93
+ for (const { candidate } of originals) {
94
+ const youtubeUrl = `https://www.youtube.com/watch?v=${candidate.videoId}`;
95
+ if (attempted.has(youtubeUrl) || attempted.size >= 3) continue;
96
+ attempted.add(youtubeUrl);
97
+ try {
98
+ // Keep YouTube's separate streams and client fallback intact for each upload.
99
+ const youtube = await resolveYoutube({ ...input, url: youtubeUrl });
100
+ return { youtube, youtubeUrl, liveFallback: false };
101
+ } catch (error) {
102
+ resolutionError = error;
103
+ }
104
+ }
105
+ }
106
+ // Try at most two live performances, after all searches for the original.
107
+ const performances = [...live].sort((left, right) => right[1] - left[1]).slice(0, 2);
108
+ for (const [youtubeUrl] of performances) {
109
+ try {
110
+ const youtube = await resolveYoutube({ ...input, url: youtubeUrl });
111
+ return { youtube, youtubeUrl, liveFallback: true };
112
+ } catch (error) {
113
+ resolutionError = error;
114
+ }
115
+ }
116
+ if (resolutionError) throw resolutionError;
117
+ if (searchError) throw searchError;
118
+ throw new PostfetchError(404, "No matching YouTube video found for this Spotify track", "noMatch");
119
+ }
120
+
121
+ async function searchYoutube(input: ResolveContext, query: string): Promise<Json[]> {
122
+ const url = new URL("https://www.youtube.com/results");
123
+ url.searchParams.set("search_query", query);
124
+ url.searchParams.set("hl", "en");
125
+ const response = await input.net(url.href, {
126
+ headers: { "accept-language": "en-US,en;q=0.9", cookie: "SOCS=CAI", "user-agent": browserUserAgent() },
127
+ });
128
+ if (!response.ok) {
129
+ throw new PostfetchError(502, `YouTube search failed: ${response.status}`);
130
+ }
131
+ const html = await response.text();
132
+ // Read the JSON script or legacy assignment without evaluating JavaScript.
133
+ // Assignment parsing balances the object and ignores trailing statements.
134
+ const raw = html.match(/(?:\bytInitialData|window\[["']ytInitialData["']\])\s*=\s*(\{[\s\S]*?)<\/script>/)?.[1];
135
+ const jsonScript = html.match(/<script\b[^>]*\bid=["']yt-initial-data["'][^>]*>([\s\S]*?)<\/script>/i)?.[1];
136
+ const payload = parseJson(jsonScript) ?? parseAssignment(raw);
137
+ if (!payload || !object(payload.contents)) {
138
+ throw new PostfetchError(502, "YouTube search results not found");
139
+ }
140
+ return videoRenderers(payload.contents);
141
+ }
142
+
143
+ function videoRenderers(value: unknown): Json[] {
144
+ if (Array.isArray(value)) return value.flatMap(videoRenderers);
145
+ if (!object(value)) return [];
146
+ if (object(value.videoRenderer)) return [value.videoRenderer];
147
+ return Object.values(value).flatMap(videoRenderers);
148
+ }
149
+
150
+ function text(value: unknown): string {
151
+ if (!object(value)) return "";
152
+ return string(value.simpleText) ?? (Array.isArray(value.runs)
153
+ ? value.runs.filter(object).map((run) => typeof run.text === "string" ? run.text : "").join("")
154
+ : "");
155
+ }
156
+
157
+ function normalize(value: string): string {
158
+ return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
159
+ }
160
+
161
+ function contains(haystack: string, needle: string): boolean {
162
+ return needle.length > 0 && ` ${haystack} `.includes(` ${needle} `);
163
+ }
164
+
165
+ function artistMatches(value: string, artist: string): boolean {
166
+ if (contains(value, artist)) return true;
167
+ // Recognize long, vowel-free abbreviations such as Chkbns / Cheekbones.
168
+ // Match whole words, never a short/common acronym or arbitrary edit distance.
169
+ return /^[bcdfghjklmnpqrstvwxyz]{6,}$/.test(artist) && value.split(" ").some((word) =>
170
+ /^[a-z]+$/.test(word) && word.replace(/[aeiou]/g, "") === artist
171
+ );
172
+ }
173
+
174
+ function durationSeconds(value: string): number | null {
175
+ if (!/^\d+(?::\d{2}){1,2}$/.test(value)) return null;
176
+ return value.split(":").reduce((seconds, part) => seconds * 60 + Number(part), 0);
177
+ }
178
+
179
+ // Some auto-generated Topic channels transpose adjacent letters in an artist's
180
+ // name. Allow one such typo in a long word only, with every other word exact.
181
+ function topicArtistMatch(author: string, artist: string): boolean {
182
+ if (!author.endsWith(" topic")) return false;
183
+ const actual = author.slice(0, -6).trim().split(" ");
184
+ const expected = artist.split(" ");
185
+ if (actual.length !== expected.length) return false;
186
+ let typos = 0;
187
+ return expected.every((word, index) => {
188
+ const candidate = actual[index];
189
+ if (candidate === word) return true;
190
+ if (word.length < 6 || candidate.length !== word.length || ++typos > 1) return false;
191
+ for (let offset = 0; offset < word.length - 1; offset += 1) {
192
+ const swapped = word.slice(0, offset) + word[offset + 1] + word[offset] + word.slice(offset + 2);
193
+ if (candidate === swapped) return true;
194
+ }
195
+ return false;
196
+ });
197
+ }
198
+
199
+ function matchScore(track: Track, candidate: Json, allowLive = false): number {
200
+ if (typeof candidate.videoId !== "string" || !/^[A-Za-z0-9_-]{11}$/.test(candidate.videoId)) return -Infinity;
201
+ const title = normalize(text(candidate.title));
202
+ const author = normalize(text(candidate.ownerText) || text(candidate.longBylineText) || text(candidate.shortBylineText));
203
+ const trackTitle = track.title.replace(/\s*[([](?:feat\.?|ft\.?).*?[)\]]/gi, "");
204
+ const expected = normalize(trackTitle);
205
+ const artists = track.artists.map(normalize);
206
+ const artistUpload = artistMatches(author, artists[0]) || topicArtistMatch(author, artists[0]);
207
+ const credited = artists.map((artist, index) => artistMatches(`${title} ${author}`, artist) || (index === 0 && artistUpload));
208
+ if (!credited[0]) return -Infinity;
209
+ const tokens = expected.split(" ").filter(Boolean);
210
+ if (tokens.length === 0) return -Infinity;
211
+ let coverage = tokens.filter((token) => contains(title, token)).length / tokens.length;
212
+ const joinedTitle = expected.replace(/ /g, "");
213
+ const joinedMatch = tokens.length > 1 && joinedTitle.length >= 6 && contains(title, joinedTitle);
214
+ if (joinedMatch) coverage = 1;
215
+ if (coverage < (tokens.length <= 3 ? 1 : 0.8) || (tokens.length <= 3 && !contains(title, expected) && !joinedMatch)) {
216
+ // A soundtrack title may prefix the song and its show/version with project
217
+ // context, e.g. "Project: Subtitle - Song - Show". Require the complete
218
+ // remaining phrase and every credited artist before accepting its omission.
219
+ const parts = trackTitle.split(/\s+[-–—|]\s+/);
220
+ if (parts.length < 3 || !parts[0].includes(":") || artists.length < 2 || !credited.every(Boolean) || !contains(title, normalize(parts.slice(1).join(" ")))) {
221
+ return -Infinity;
222
+ }
223
+ coverage = 1;
224
+ }
225
+ const duration = durationSeconds(text(candidate.lengthText));
226
+ if (duration === null || duration <= 0) return -Infinity; // Excludes live streams and upcoming videos.
227
+ const tolerance = Math.min(30, Math.max(12, (track.durationSeconds ?? duration) * 0.08));
228
+ const delta = track.durationSeconds === undefined ? 0 : Math.abs(duration - track.durationSeconds);
229
+ if (delta > tolerance) return -Infinity;
230
+ // Do not silently substitute a different recording or speed edit. Artist names
231
+ // are removed first so e.g. a band called "Live" is not treated as a live edit.
232
+ let recordingTitle = title;
233
+ for (const artist of artists) recordingTitle = recordingTitle.replace(artist, "");
234
+ const isLive = /\blive\b/.test(recordingTitle);
235
+ const requestedLive = /\blive\b/.test(expected);
236
+ if (isLive !== requestedLive && !(allowLive && isLive && !requestedLive)) return -Infinity;
237
+ for (const version of [/\bremix\b/, /\bcover\b/, /\bkaraoke\b/, /\binstrumental\b/, /\bslowed\b/, /\b(?:sped|speed) up\b/, /\bnightcore\b/, /\bacoustic\b/]) {
238
+ if (version.test(recordingTitle) !== version.test(expected)) return -Infinity;
239
+ }
240
+ const creditCoverage = credited.filter(Boolean).length / artists.length;
241
+ return coverage * 10 + (artistUpload ? 8 : 0) + creditCoverage * 2 + (contains(title, "official") ? 2 : 0) + 3 * (1 - delta / tolerance);
242
+ }
243
+
244
+ function parseJson(value: string | undefined): Json | null {
245
+ try {
246
+ const parsed: unknown = JSON.parse(value ?? "");
247
+ return object(parsed) ? parsed : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ function parseAssignment(value: string | undefined): Json | null {
254
+ if (!value) return null;
255
+ // Balance braces outside JSON strings so titles containing `};` cannot truncate
256
+ // the payload, and trailing statements in the same script are ignored.
257
+ let depth = 0;
258
+ let quoted = false;
259
+ let escaped = false;
260
+ for (let index = 0; index < value.length; index += 1) {
261
+ const character = value[index];
262
+ if (quoted) {
263
+ if (escaped) escaped = false;
264
+ else if (character === "\\") escaped = true;
265
+ else if (character === '"') quoted = false;
266
+ } else if (character === '"') quoted = true;
267
+ else if (character === "{") depth += 1;
268
+ else if (character === "}" && --depth === 0) return parseJson(value.slice(0, index + 1));
269
+ }
270
+ return null;
271
+ }