@postfetch/core 0.2.4 → 0.3.0

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.
package/src/remux.ts ADDED
@@ -0,0 +1,172 @@
1
+ import {
2
+ boxVersion,
3
+ buildBox,
4
+ childBoxes,
5
+ concat,
6
+ findBox,
7
+ parseBoxes,
8
+ readU32,
9
+ sliceBox,
10
+ writeU32,
11
+ type Box,
12
+ } from "./isobmff";
13
+
14
+ // A single-track fragmented MP4 (CMAF init segment + moof/mdat fragments), broken
15
+ // into the parts a merge needs.
16
+ type Source = {
17
+ data: Uint8Array;
18
+ ftyp: Box;
19
+ moov: Box;
20
+ trak: Box;
21
+ mvex: Box;
22
+ trackId: number;
23
+ fragments: { moof: Box; mdat: Box }[];
24
+ };
25
+
26
+ /**
27
+ * Merge a video-only and an audio-only fragmented MP4 into one file with both
28
+ * tracks. The DASH/CMAF streams reddit and YouTube hand out keep video and audio
29
+ * apart; this recombines them without re-encoding — every `moof`/`mdat` pair is
30
+ * copied verbatim (so each `trun` data offset stays valid) and only the track id
31
+ * and fragment sequence number are patched.
32
+ */
33
+ export function mergeAudioVideo(video: Uint8Array, audio: Uint8Array): Uint8Array {
34
+ const videoSource = analyze(video);
35
+ const audioSource = analyze(audio);
36
+ const audioTrackId = videoSource.trackId === 1 ? 2 : 1;
37
+
38
+ const moov = buildMoov(videoSource, audioSource, audioTrackId);
39
+ const parts: Uint8Array[] = [sliceBox(video, videoSource.ftyp), moov];
40
+
41
+ let sequence = 1;
42
+ const fragmentCount = Math.max(videoSource.fragments.length, audioSource.fragments.length);
43
+ for (let index = 0; index < fragmentCount; index += 1) {
44
+ const videoFragment = videoSource.fragments[index];
45
+ if (videoFragment) {
46
+ // The patched moof is a small copy; the (large) mdat is appended as a view,
47
+ // so the trailing concat is the only time sample bytes are copied.
48
+ parts.push(rewriteMoof(video, videoFragment.moof, null, sequence));
49
+ parts.push(video.subarray(videoFragment.mdat.start, videoFragment.mdat.end));
50
+ sequence += 1;
51
+ }
52
+ const audioFragment = audioSource.fragments[index];
53
+ if (audioFragment) {
54
+ parts.push(rewriteMoof(audio, audioFragment.moof, audioTrackId, sequence));
55
+ parts.push(audio.subarray(audioFragment.mdat.start, audioFragment.mdat.end));
56
+ sequence += 1;
57
+ }
58
+ }
59
+ return concat(parts);
60
+ }
61
+
62
+ function analyze(data: Uint8Array): Source {
63
+ const top = parseBoxes(data);
64
+ const ftyp = findBox(top, "ftyp");
65
+ const moov = findBox(top, "moov");
66
+ if (!ftyp || !moov) {
67
+ throw new Error("not a fragmented MP4");
68
+ }
69
+ const moovChildren = childBoxes(data, moov);
70
+ const trak = findBox(moovChildren, "trak");
71
+ const mvex = findBox(moovChildren, "mvex");
72
+ if (!trak || !mvex) {
73
+ throw new Error("MP4 is not fragmented (no trak/mvex)");
74
+ }
75
+ const tkhd = findBox(childBoxes(data, trak), "tkhd");
76
+ if (!tkhd) {
77
+ throw new Error("track header not found");
78
+ }
79
+ const fragments: { moof: Box; mdat: Box }[] = [];
80
+ for (let index = 0; index < top.length; index += 1) {
81
+ if (top[index].type === "moof") {
82
+ const mdat = top[index + 1];
83
+ if (!mdat || mdat.type !== "mdat") {
84
+ throw new Error("fragment without mdat");
85
+ }
86
+ fragments.push({ moof: top[index], mdat });
87
+ }
88
+ }
89
+ if (fragments.length === 0) {
90
+ throw new Error("MP4 has no fragments");
91
+ }
92
+ return { data, ftyp, moov, trak, mvex, trackId: trackHeaderId(data, tkhd), fragments };
93
+ }
94
+
95
+ // next_track_ID is the last uint32 of mvhd; track_ID sits after the version-sized
96
+ // creation/modification timestamps in tkhd.
97
+ function trackHeaderId(data: Uint8Array, tkhd: Box): number {
98
+ return readU32(data, tkhd.dataStart + (boxVersion(data, tkhd) === 1 ? 20 : 12));
99
+ }
100
+
101
+ function buildMoov(video: Source, audio: Source, audioTrackId: number): Uint8Array {
102
+ const moovChildren = childBoxes(video.data, video.moov);
103
+ const mvhd = patchedMvhd(video, audioTrackId);
104
+ const meta = findBox(moovChildren, "meta");
105
+ const videoTrak = sliceBox(video.data, video.trak);
106
+ const audioTrak = retrackTrak(sliceBox(audio.data, audio.trak), audioTrackId);
107
+ const mvex = buildMvex(video, audio, audioTrackId);
108
+
109
+ const children = [mvhd];
110
+ if (meta) {
111
+ children.push(sliceBox(video.data, meta));
112
+ }
113
+ children.push(videoTrak, audioTrak, mvex);
114
+ return buildBox("moov", concat(children));
115
+ }
116
+
117
+ function patchedMvhd(video: Source, audioTrackId: number): Uint8Array {
118
+ const mvhd = findBox(childBoxes(video.data, video.moov), "mvhd");
119
+ if (!mvhd) {
120
+ throw new Error("movie header not found");
121
+ }
122
+ const copy = sliceBox(video.data, mvhd);
123
+ writeU32(copy, copy.length - 4, Math.max(video.trackId, audioTrackId) + 1);
124
+ return copy;
125
+ }
126
+
127
+ function buildMvex(video: Source, audio: Source, audioTrackId: number): Uint8Array {
128
+ const videoMvex = childBoxes(video.data, video.mvex);
129
+ const mehd = findBox(videoMvex, "mehd");
130
+ const videoTrex = findBox(videoMvex, "trex");
131
+ const audioTrex = findBox(childBoxes(audio.data, audio.mvex), "trex");
132
+ if (!videoTrex || !audioTrex) {
133
+ throw new Error("track extends box not found");
134
+ }
135
+ const audioTrexCopy = sliceBox(audio.data, audioTrex);
136
+ writeU32(audioTrexCopy, audioTrex.dataStart - audioTrex.start + 4, audioTrackId);
137
+
138
+ const children: Uint8Array[] = [];
139
+ if (mehd) {
140
+ children.push(sliceBox(video.data, mehd));
141
+ }
142
+ children.push(sliceBox(video.data, videoTrex), audioTrexCopy);
143
+ return buildBox("mvex", concat(children));
144
+ }
145
+
146
+ function retrackTrak(trak: Uint8Array, trackId: number): Uint8Array {
147
+ const tkhd = findBox(parseBoxes(trak, 8, trak.length), "tkhd");
148
+ if (!tkhd) {
149
+ throw new Error("track header not found");
150
+ }
151
+ writeU32(trak, tkhd.dataStart + (boxVersion(trak, tkhd) === 1 ? 20 : 12), trackId);
152
+ return trak;
153
+ }
154
+
155
+ function rewriteMoof(data: Uint8Array, moofBox: Box, trackId: number | null, sequence: number): Uint8Array {
156
+ const moof = sliceBox(data, moofBox);
157
+ const moofChildren = parseBoxes(moof, 8, moof.length);
158
+ const mfhd = findBox(moofChildren, "mfhd");
159
+ if (!mfhd) {
160
+ throw new Error("movie fragment header not found");
161
+ }
162
+ writeU32(moof, mfhd.dataStart + 4, sequence);
163
+ if (trackId !== null) {
164
+ const traf = findBox(moofChildren, "traf");
165
+ const tfhd = traf ? findBox(parseBoxes(moof, traf.dataStart, traf.end), "tfhd") : undefined;
166
+ if (!tfhd) {
167
+ throw new Error("track fragment header not found");
168
+ }
169
+ writeU32(moof, tfhd.dataStart + 4, trackId);
170
+ }
171
+ return moof;
172
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ number,
5
+ object,
6
+ string,
7
+ type ResolveContext,
8
+ type Json,
9
+ type Net,
10
+ type PostfetchResult,
11
+ type MediaItem,
12
+ } from "./internal";
13
+ import { browserUserAgent } from "./fingerprint";
14
+
15
+ export async function resolveSoundcloud(input: ResolveContext): Promise<PostfetchResult> {
16
+ const url = await canonicalUrl(input.net, input.url);
17
+ const clientId = await appClientId(input.net);
18
+ const track = await resolveTrack(input.net, url, clientId);
19
+ const id = trackId(track);
20
+ const item = await audioItem(input.net, track, id, clientId);
21
+ return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], platform: "soundcloud" };
22
+ }
23
+
24
+ async function canonicalUrl(net: Net, input: string): Promise<string> {
25
+ const host = asUrl(input).hostname;
26
+ if (host !== "on.soundcloud.com" && host !== "snd.sc") {
27
+ return input;
28
+ }
29
+ const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
30
+ return response.url;
31
+ }
32
+
33
+ // SoundCloud's public api-v2 needs a client_id, which the web app ships inside one
34
+ // of its asset bundles. The bundle that carries it changes, so the script srcs are
35
+ // scanned (newest first) until one yields the token.
36
+ async function appClientId(net: Net): Promise<string> {
37
+ const home = await net("https://soundcloud.com/", { headers: { "user-agent": browserUserAgent() } });
38
+ if (!home.ok) {
39
+ throw new Error(`SoundCloud home failed: ${home.status}`);
40
+ }
41
+ const html = await home.text();
42
+ const scripts = [...html.matchAll(/<script[^>]+src="(https:\/\/a-v2\.sndcdn\.com\/assets\/[^"]+\.js)"/g)].map((match) => match[1]);
43
+ for (const src of scripts.reverse()) {
44
+ const response = await net(src, { headers: { "user-agent": browserUserAgent() } }, 1);
45
+ if (!response.ok) {
46
+ continue;
47
+ }
48
+ const code = await response.text();
49
+ const id = code.match(/client_id\s*[:=]\s*"([A-Za-z0-9]{20,})"/)?.[1];
50
+ if (id) {
51
+ return id;
52
+ }
53
+ }
54
+ throw new Error("SoundCloud client id not found");
55
+ }
56
+
57
+ async function resolveTrack(net: Net, url: string, clientId: string): Promise<Json> {
58
+ const api = new URL("https://api-v2.soundcloud.com/resolve");
59
+ api.searchParams.set("url", url);
60
+ api.searchParams.set("client_id", clientId);
61
+ const response = await net(api.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
62
+ if (!response.ok) {
63
+ throw new Error(`SoundCloud resolve failed: ${response.status}`);
64
+ }
65
+ const payload = await response.json().catch(() => null);
66
+ if (!object(payload) || string(payload.kind) !== "track") {
67
+ throw new Error("SoundCloud track not found");
68
+ }
69
+ return payload;
70
+ }
71
+
72
+ async function audioItem(net: Net, track: Json, id: string, clientId: string): Promise<MediaItem> {
73
+ const media = object(track.media) ? track.media : null;
74
+ const transcodings = media && Array.isArray(media.transcodings) ? media.transcodings.filter(object) : [];
75
+ // Prefer the direct progressive mp3; otherwise assemble the AAC HLS playlist
76
+ // (its CMAF segments concatenate into a fragmented mp4) at download time.
77
+ const progressive = transcodings.find((transcoding) => protocol(transcoding) === "progressive");
78
+ const hls = transcodings.find((transcoding) => protocol(transcoding) === "hls" && mimeType(transcoding)?.includes("mp4") === true);
79
+ const chosen = progressive ?? hls;
80
+ if (!chosen) {
81
+ throw new Error("SoundCloud track has no downloadable stream");
82
+ }
83
+ const stream = await streamUrl(net, chosen, clientId);
84
+ const title = string(track.title) ?? id;
85
+ const headers = { "user-agent": browserUserAgent() };
86
+ if (chosen === progressive) {
87
+ return { filename: filename(`soundcloud_${title}.mp3`), headers, id, kind: "audio", mime: "audio/mpeg", platform: "soundcloud", url: stream };
88
+ }
89
+ return { filename: filename(`soundcloud_${title}.m4a`), headers, hls: true, id, kind: "audio", mime: "audio/mp4", platform: "soundcloud", url: stream };
90
+ }
91
+
92
+ async function streamUrl(net: Net, transcoding: Json, clientId: string): Promise<string> {
93
+ const endpoint = string(transcoding.url);
94
+ if (!endpoint) {
95
+ throw new Error("SoundCloud stream endpoint not found");
96
+ }
97
+ const url = new URL(endpoint);
98
+ url.searchParams.set("client_id", clientId);
99
+ const response = await net(url.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
100
+ if (!response.ok) {
101
+ throw new Error(`SoundCloud stream failed: ${response.status}`);
102
+ }
103
+ const payload = await response.json().catch(() => null);
104
+ const stream = object(payload) ? string(payload.url) : null;
105
+ if (!stream) {
106
+ throw new Error("SoundCloud stream url not found");
107
+ }
108
+ return stream;
109
+ }
110
+
111
+ function protocol(transcoding: Json): string | null {
112
+ const format = object(transcoding.format) ? transcoding.format : null;
113
+ return format ? string(format.protocol) : null;
114
+ }
115
+
116
+ function mimeType(transcoding: Json): string | null {
117
+ const format = object(transcoding.format) ? transcoding.format : null;
118
+ return format ? string(format.mime_type) : null;
119
+ }
120
+
121
+ function trackId(track: Json): string {
122
+ const numeric = number(track.id);
123
+ if (numeric !== null) {
124
+ return String(numeric);
125
+ }
126
+ return string(track.permalink) ?? "track";
127
+ }
package/src/youtube.ts CHANGED
@@ -47,23 +47,72 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
47
47
  const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
48
48
  throw new Error(reason ?? "YouTube video unavailable");
49
49
  }
50
- const format = selectFormat(payload);
51
- if (!format) {
52
- throw new Error("YouTube progressive mp4 not found");
50
+ const streams = selectStreams(payload, input.preferredWidth);
51
+ if (!streams) {
52
+ throw new Error("YouTube mp4 stream not found");
53
53
  }
54
54
  const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
55
+ const headers = { "user-agent": androidVrClient.userAgent };
55
56
  const media: MediaItem = {
56
57
  filename: filename(`youtube_${title ?? id}_${id}.mp4`),
57
- headers: { "user-agent": androidVrClient.userAgent },
58
+ headers,
58
59
  id,
59
60
  kind: "video",
60
61
  mime: "video/mp4",
61
62
  platform: "youtube",
62
- url: format,
63
+ url: streams.video,
64
+ ...(streams.audio ? { audio: { headers, url: streams.audio } } : {}),
63
65
  };
64
66
  return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], platform: "youtube" };
65
67
  }
66
68
 
69
+ // Adaptive H.264 video and AAC audio go up to 1080p with direct URLs; merging them
70
+ // beats the progressive formats (capped at 360-720p). The progressive single-file
71
+ // stream is the fallback when a separate-stream pair is unavailable.
72
+ function selectStreams(payload: unknown, preferredWidth: number): { video: string; audio: string | null } | null {
73
+ const adaptive = adaptiveFormats(payload);
74
+ const video = bestVideo(adaptive, preferredWidth);
75
+ const audio = bestAudio(adaptive);
76
+ if (video && audio) {
77
+ return { video, audio };
78
+ }
79
+ const progressive = selectFormat(payload);
80
+ return progressive ? { video: progressive, audio: null } : null;
81
+ }
82
+
83
+ function adaptiveFormats(payload: unknown): Json[] {
84
+ const root = object(payload) ? payload : null;
85
+ const streaming = root && object(root.streamingData) ? root.streamingData : null;
86
+ return streaming && Array.isArray(streaming.adaptiveFormats) ? streaming.adaptiveFormats.filter(object) : [];
87
+ }
88
+
89
+ function bestVideo(formats: Json[], preferredWidth: number): string | null {
90
+ const candidates = formats.filter((format) => {
91
+ const mimeType = string(format.mimeType);
92
+ return Boolean(string(format.url)) && mimeType?.startsWith("video/mp4") === true && mimeType.includes("avc1");
93
+ });
94
+ const best = candidates.reduce<Json | null>((current, format) => {
95
+ if (!current) {
96
+ return format;
97
+ }
98
+ const width = number(format.width) ?? 0;
99
+ const currentWidth = number(current.width) ?? 0;
100
+ return Math.abs(width - preferredWidth) < Math.abs(currentWidth - preferredWidth) ? format : current;
101
+ }, null);
102
+ return best ? string(best.url) : null;
103
+ }
104
+
105
+ function bestAudio(formats: Json[]): string | null {
106
+ const candidates = formats.filter((format) => Boolean(string(format.url)) && string(format.mimeType)?.startsWith("audio/mp4") === true);
107
+ const best = candidates.reduce<Json | null>((current, format) => {
108
+ if (!current) {
109
+ return format;
110
+ }
111
+ return (number(format.bitrate) ?? 0) > (number(current.bitrate) ?? 0) ? format : current;
112
+ }, null);
113
+ return best ? string(best.url) : null;
114
+ }
115
+
67
116
  function playerBody(id: string, session: YoutubeSession): Json {
68
117
  return {
69
118
  contentCheckOk: true,