@serifpersia/dango 3.4.4 → 3.4.6

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.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-4CEz8Buu.js → AnimeCard-BVdH0EeW.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-DyX9qTwg.js → AnimeInfo-Dw2Nop6Q.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-Dvp_c8vK.js → AnimeInfoPage-Cw-Tq3YX.js} +2 -2
  5. package/client/dist/assets/{Asmr-qg3lMAJn.js → Asmr-BrjzzyOi.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-BpPlg_Vv.js → AsmrResetProgressModal-CYjpcYpb.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-FJllOgOH.js → AvSyncCalibrator-BuHurVgo.js} +3 -3
  8. package/client/dist/assets/AvSyncCalibrator-D0FYSwiz.css +1 -0
  9. package/client/dist/assets/{Home-A7gCeEST.js → Home-KdnP7sNM.js} +1 -1
  10. package/client/dist/assets/{Insights-G9IXepHJ.js → Insights-DDWry-t1.js} +1 -1
  11. package/client/dist/assets/{LibraryListPage-Bh2qCJPh.js → LibraryListPage-CV8Z9JlO.js} +1 -1
  12. package/client/dist/assets/{ListeningList-Czv4cTRl.js → ListeningList-DSMbMZT7.js} +1 -1
  13. package/client/dist/assets/{Manga-qdAAS1z7.js → Manga-J6XoqEKq.js} +1 -1
  14. package/client/dist/assets/{MangaInfoPage-1rTgZzf2.js → MangaInfoPage-N7TxsN5u.js} +1 -1
  15. package/client/dist/assets/{MangaReadPage-CeyxPG6w.js → MangaReadPage-D_VzSNWq.js} +1 -1
  16. package/client/dist/assets/{MangaResetProgressModal-C_ppotmh.js → MangaResetProgressModal-DyhQYBDy.js} +1 -1
  17. package/client/dist/assets/{Mature-Bkhxnr3K.js → Mature-Db53R-LR.js} +1 -1
  18. package/client/dist/assets/{MediaCard-BQZsutEo.js → MediaCard-BcLjlpfR.js} +1 -1
  19. package/client/dist/assets/{Music-C0rGzSbi.js → Music-DjxQjJOl.js} +1 -1
  20. package/client/dist/assets/{OptionListMenu-CyfpCBEy.js → OptionListMenu-DngTgHxH.js} +1 -1
  21. package/client/dist/assets/Player-Be_7Ne5p.js +56 -0
  22. package/client/dist/assets/{PlayerSettings-icy4SfQa.js → PlayerSettings-D47ItGDR.js} +1 -1
  23. package/client/dist/assets/{QueueOptionsButton-5eloKF5J.js → QueueOptionsButton-ClVTpH8J.js} +1 -1
  24. package/client/dist/assets/{QueueRail-DaMjlKL4.js → QueueRail-DeJKVRs5.js} +1 -1
  25. package/client/dist/assets/{Radio-FxGqCxR4.js → Radio-DU0ma3GA.js} +1 -1
  26. package/client/dist/assets/{ReadingList-CCm3IAbt.js → ReadingList-qXW7Dk5z.js} +1 -1
  27. package/client/dist/assets/{ResetProgressModal-CeI4V3Y5.js → ResetProgressModal-C6ukct_c.js} +1 -1
  28. package/client/dist/assets/{Search-qpILaAiQ.js → Search-DjjKA_Wu.js} +1 -1
  29. package/client/dist/assets/{Settings-Bw9M3NGz.js → Settings-DQt_S5Qw.js} +1 -1
  30. package/client/dist/assets/{SkeletonGrid-0dY_xMq3.js → SkeletonGrid-nx2HEi4P.js} +1 -1
  31. package/client/dist/assets/{Trackers-Zy5GOs0P.js → Trackers-BcGzeqj2.js} +1 -1
  32. package/client/dist/assets/Tv-COvyHdeJ.js +4 -0
  33. package/client/dist/assets/{TvInfo-CHIsVzZ0.js → TvInfo-CUd1agFy.js} +1 -1
  34. package/client/dist/assets/{TvPopup-D_tVTyZ2.js → TvPopup-CJeu3tgc.js} +1 -1
  35. package/client/dist/assets/{TvSearch-BVxedBW-.js → TvSearch-BIypjbU3.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-BEIwq9R9.js → TvWatchlist-DZrBdiqZ.js} +1 -1
  37. package/client/dist/assets/{Watchlist-CSngjqlz.js → Watchlist-Dw8cPe0n.js} +1 -1
  38. package/client/dist/assets/{hls-D48GVTkk.js → hls-B3_1VuDP.js} +1 -1
  39. package/client/dist/assets/{index-CVB5dYhT.js → index-gMxawd-E.js} +2 -2
  40. package/client/dist/assets/{useAnimeInfoData-DxdOg_B7.js → useAnimeInfoData-_iOLEAa8.js} +1 -1
  41. package/client/dist/assets/{useAsmr-ClckxnPe.js → useAsmr-Blk4v41t.js} +1 -1
  42. package/client/dist/assets/{useAsmrPopup-Bbgxefy_.js → useAsmrPopup-D9SQIgZa.js} +1 -1
  43. package/client/dist/assets/{useMangaLibrary-B3_BUKVk.js → useMangaLibrary-B60at6ni.js} +1 -1
  44. package/client/dist/assets/{useMangaPopup-Celk4lzf.js → useMangaPopup-DEfGjWSu.js} +1 -1
  45. package/client/dist/assets/{useProviders-pwxPZPHh.js → useProviders-Ca0bZWt7.js} +1 -1
  46. package/client/dist/assets/{useTv-D9sQrDop.js → useTv-WJCDL5_5.js} +1 -1
  47. package/client/dist/assets/{useTvLibrary-UsQpgBTB.js → useTvLibrary-Fgn5i9rJ.js} +1 -1
  48. package/client/dist/index.html +1 -1
  49. package/package.json +4 -2
  50. package/server/dist/app-hono.js +2 -0
  51. package/server/dist/hono/asmr.js +10 -1
  52. package/server/dist/hono/music.js +122 -332
  53. package/server/dist/hono/proxy.js +105 -99
  54. package/server/dist/hono/tv.js +5 -58
  55. package/server/dist/hono/watchlist.js +2 -291
  56. package/server/dist/lib/anilist.js +0 -3
  57. package/server/dist/lib/mal.js +4 -41
  58. package/server/dist/lib/offline-db.js +0 -6
  59. package/server/dist/lib/potoken.js +135 -0
  60. package/server/dist/lib/tracker/manga-sync.service.js +3 -22
  61. package/server/dist/lib/tracker/sync-state.js +21 -0
  62. package/server/dist/lib/tracker/sync.service.js +3 -22
  63. package/server/dist/lib/ump.js +106 -0
  64. package/server/dist/providers/remote-loader.js +17 -27
  65. package/server/dist/providers/title-matching.js +5 -5
  66. package/server/dist/repositories/asmr.repository.js +4 -53
  67. package/server/dist/repositories/library.repository.js +46 -0
  68. package/server/dist/repositories/manga.repository.js +3 -53
  69. package/server/dist/repositories/settings.repository.js +8 -0
  70. package/server/dist/repositories/tv.repository.js +3 -46
  71. package/server/dist/repositories/watched-episodes.repository.js +0 -2
  72. package/server/dist/server.js +5 -1
  73. package/server/dist/sync.js +3 -5
  74. package/server/dist/trpc/routers/asmr-library.js +2 -12
  75. package/server/dist/trpc/routers/asmr-progress.js +10 -22
  76. package/server/dist/trpc/routers/data.js +17 -36
  77. package/server/dist/trpc/routers/discord.js +1 -8
  78. package/server/dist/trpc/routers/insights.js +15 -16
  79. package/server/dist/trpc/routers/manga-library.js +5 -22
  80. package/server/dist/trpc/routers/manga-progress.js +8 -20
  81. package/server/dist/trpc/routers/music.js +6 -11
  82. package/server/dist/trpc/routers/queue.js +1 -1
  83. package/server/dist/trpc/routers/tracker.js +1 -7
  84. package/server/dist/trpc/routers/tv-library.js +2 -12
  85. package/server/dist/trpc/routers/tv-progress.js +16 -28
  86. package/server/dist/trpc/routers/tv.js +5 -21
  87. package/server/dist/trpc/routers/watchlist.js +1 -8
  88. package/server/dist/trpc/validation.js +35 -0
  89. package/server/dist/utils/db-utils.js +8 -0
  90. package/server/dist/utils/http.utils.js +12 -0
  91. package/server/dist/utils/machine-id.js +1 -2
  92. package/server/dist/utils/megaplay.utils.js +1 -2
  93. package/server/package.json +4 -1
  94. package/client/dist/assets/AvSyncCalibrator-C2wu1ydI.css +0 -1
  95. package/client/dist/assets/Player-yhiTlpYU.js +0 -56
  96. package/client/dist/assets/Tv-DREwc7BX.js +0 -4
@@ -1,292 +1,48 @@
1
- import { AppCache } from '../utils/cache.utils.js';
2
1
  import logger from '../logger.js';
3
- import { getInnertube, getAuthedInnertube, refreshAuthedInnertube, isParserVariantError, } from '../lib/ytmusic.js';
4
- const musicCache = new AppCache({ ttlSeconds: 3600, maxKeys: 5000 });
5
- const NAME_SUFFIXES = [
6
- /\s*(- topic)$/i,
7
- /\s*vevo$/i,
8
- /\s*[(|[]official(.*?)[)|\]]/i,
9
- /\s*[(|[]((lyrics?|visualizer|audio)\s*(video)?)[)|\]]/i,
10
- /\s*[(|[](performance video)[)|\]]/i,
11
- /\s*[(|[](clip official)[)|\]]/i,
12
- /\s*[(|[](video version)[)|\]]/i,
13
- /\s*[(|[](HD|HQ)\s*?(?:audio)?[)|\]]$/i,
14
- /\s*[(|[](live)[)|\]]$/i,
15
- /\s*[(|[]4K\s*?(?:upgrade)?[)|\]]$/i,
16
- ];
17
- export function cleanupMusicName(name) {
18
- if (!name)
19
- return name;
20
- let out = name;
21
- for (const re of NAME_SUFFIXES)
22
- out = out.replace(re, '');
23
- return out.trim();
24
- }
25
- function toTrack(node) {
26
- const n = node;
27
- const id = n['video_id'] || n['videoId'] || n['id'] || '';
28
- if (!id || typeof id !== 'string')
29
- return null;
30
- const titleObj = n['title'];
31
- const title = typeof titleObj === 'string' ? titleObj : (titleObj?.text ?? 'Unknown title');
32
- const artistsRaw = n['artists'] ?? n['authors'] ?? n['subtitle'];
33
- let artists = '';
34
- if (Array.isArray(artistsRaw)) {
35
- artists = artistsRaw
36
- .map((a) => {
37
- const item = a;
38
- return item.name || item.text || item.title || '';
39
- })
40
- .filter(Boolean)
41
- .join(', ');
42
- }
43
- else if (typeof artistsRaw === 'string') {
44
- artists = artistsRaw;
45
- }
46
- else if (artistsRaw && typeof artistsRaw === 'object') {
47
- artists = artistsRaw.text ?? '';
48
- }
49
- const thumbsRaw = (n['thumbnails'] ?? n['thumbnail']);
50
- const thumbnails = Array.isArray(thumbsRaw)
51
- ? thumbsRaw
52
- : Array.isArray(thumbsRaw?.thumbnails)
53
- ? thumbsRaw.thumbnails
54
- : [];
55
- const albumText = textOf(n['album']);
56
- const durationText = textOf(n['duration']);
57
- return {
58
- id,
59
- title: cleanupMusicName(String(title)),
60
- artists: cleanupMusicName(artists),
61
- album: albumText ? cleanupMusicName(albumText) : undefined,
62
- duration: durationText || undefined,
63
- thumbnails,
64
- };
65
- }
66
- function textOf(value) {
67
- if (!value)
68
- return '';
69
- if (typeof value === 'string')
70
- return value;
71
- const obj = value;
72
- if (typeof obj.text === 'string')
73
- return obj.text;
74
- if (Array.isArray(obj.runs))
75
- return obj.runs.map((r) => r.text || '').join('');
76
- return '';
2
+ import { parseUmpResponse } from '../lib/ump.js';
3
+ import { mintPoToken, resetPoTokens } from '../lib/potoken.js';
4
+ import { getInnertube, getAuthedInnertube, getMusicCookie, refreshAuthedInnertube, } from '../lib/ytmusic.js';
5
+ const UPSTREAM_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0';
6
+ const audioCache = new Map();
7
+ const AUDIO_CACHE_MS = 5 * 60 * 1000;
8
+ const FIRST_CHUNK = 16384;
9
+ const AUDIO_CHUNK = 262144;
10
+ const UMP_CVER = '1.20260920.16.00';
11
+ const audioTotalCache = new Map();
12
+ const umpSessions = new Map();
13
+ function randomCpn() {
14
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
15
+ let out = '';
16
+ for (let i = 0; i < 16; i++)
17
+ out += chars[Math.floor(Math.random() * chars.length)];
18
+ return out;
77
19
  }
78
- function thumbsOf(value) {
79
- if (!value)
80
- return [];
81
- if (Array.isArray(value)) {
82
- return value
83
- .filter((t) => t && typeof t.url === 'string')
84
- .map((t) => ({ url: t.url, width: t.width, height: t.height }));
20
+ function getUmpSession(videoId) {
21
+ let session = umpSessions.get(videoId);
22
+ if (!session) {
23
+ session = { cpn: randomCpn(), rn: 0, carry: null };
24
+ umpSessions.set(videoId, session);
85
25
  }
86
- const obj = value;
87
- if (Array.isArray(obj.thumbnails))
88
- return thumbsOf(obj.thumbnails);
89
- return [];
90
- }
91
- function nodeType(node) {
92
- const n = node;
93
- if (typeof n?.type === 'string')
94
- return n.type;
95
- return n?.constructor?.name ?? '';
26
+ return session;
96
27
  }
97
- function upnextToTrack(node, trustLike = false) {
98
- const kind = nodeType(node);
99
- if (kind === 'AutomixPreviewVideo')
100
- return null;
101
- const n = node;
102
- const video = kind === 'PlaylistPanelVideoWrapper'
103
- ? (n['primary'] ?? null)
104
- : n;
105
- if (!video || typeof video !== 'object')
106
- return null;
107
- const id = video['video_id'];
108
- if (typeof id !== 'string' || !id)
109
- return null;
110
- const artistsRaw = video['artists'];
111
- let artists = '';
112
- if (Array.isArray(artistsRaw)) {
113
- artists = artistsRaw
114
- .map((a) => a?.name || '')
115
- .filter(Boolean)
116
- .join(', ');
117
- }
118
- if (!artists && typeof video['author'] === 'string')
119
- artists = video['author'];
120
- const albumRaw = video['album'];
121
- const durationRaw = video['duration'];
122
- const durationText = typeof durationRaw === 'string' ? durationRaw : (durationRaw?.text ?? '');
123
- let liked = null;
124
- if (trustLike) {
125
- for (const menu of [video['menu'], node['menu']]) {
126
- let found = null;
127
- for (const entry of asMenuItems(menu)) {
128
- if (nodeType(entry) !== 'ToggleMenuServiceItem')
129
- continue;
130
- const icon = entry['icon_type'];
131
- if (icon !== 'FAVORITE' && icon !== 'UNFAVORITE')
132
- continue;
133
- found = icon === 'UNFAVORITE';
134
- break;
135
- }
136
- if (found !== null) {
137
- liked = found;
138
- break;
139
- }
140
- }
141
- }
142
- return {
143
- id,
144
- title: cleanupMusicName(textOf(video['title']) || 'Unknown title'),
145
- artists: cleanupMusicName(artists || 'Unknown artist'),
146
- album: albumRaw?.name ? cleanupMusicName(albumRaw.name) : undefined,
147
- duration: durationText || undefined,
148
- thumbnails: thumbsOf(video['thumbnail'] ?? video['thumbnails']),
149
- ...(liked !== null ? { liked } : {}),
28
+ function boundWindow(clientRange, videoId) {
29
+ const total = audioTotalCache.get(videoId);
30
+ const clamp = (start, end) => {
31
+ if (total !== undefined)
32
+ end = Math.min(end, total - 1);
33
+ return [start, Math.max(end, start)];
150
34
  };
35
+ const m = /^bytes=(\d+)-(\d*)$/.exec((clientRange ?? '').trim());
36
+ if (m && m[2] !== '') {
37
+ const s = Number(m[1]);
38
+ const e = Number(m[2]);
39
+ if (e - s + 1 <= AUDIO_CHUNK)
40
+ return [s, e];
41
+ return clamp(s, s + AUDIO_CHUNK - 1);
42
+ }
43
+ const start = m ? Number(m[1]) : 0;
44
+ return clamp(start, start + (start === 0 ? FIRST_CHUNK : AUDIO_CHUNK) - 1);
151
45
  }
152
- function parseUpnext(panel, trustLike) {
153
- const tracks = [];
154
- const contents = panel.contents ?? [];
155
- for (const item of contents) {
156
- const track = upnextToTrack(item, trustLike);
157
- if (track)
158
- tracks.push(track);
159
- if (tracks.length >= 50)
160
- break;
161
- }
162
- const playlistId = panel.playlist_id ?? null;
163
- return { tracks, playlistId };
164
- }
165
- function asMenuItems(menu) {
166
- if (!menu || typeof menu !== 'object')
167
- return [];
168
- const items = menu.items;
169
- return Array.isArray(items) ? items : [];
170
- }
171
- function findLikeToggle(item, videoId) {
172
- const result = { like: null, unlike: null };
173
- const kind = nodeType(item);
174
- const n = item;
175
- const video = kind === 'PlaylistPanelVideoWrapper'
176
- ? (n['primary'] ?? null)
177
- : n;
178
- if (!video || video['video_id'] !== videoId)
179
- return result;
180
- const menus = [video['menu'], n['menu']];
181
- for (const menu of menus) {
182
- for (const entry of asMenuItems(menu)) {
183
- if (nodeType(entry) !== 'ToggleMenuServiceItem')
184
- continue;
185
- const icon = entry['icon_type'];
186
- if (icon !== 'FAVORITE' && icon !== 'UNFAVORITE')
187
- continue;
188
- for (const key of ['default_endpoint', 'toggled_endpoint']) {
189
- const ep = entry[key];
190
- if (typeof ep?.call !== 'function')
191
- continue;
192
- const status = ep.payload?.status;
193
- if (status === 'LIKE' && !result.like)
194
- result.like = ep;
195
- else if (status === 'INDIFFERENT' && !result.unlike)
196
- result.unlike = ep;
197
- }
198
- if (result.like && result.unlike)
199
- return result;
200
- }
201
- }
202
- return result;
203
- }
204
- async function fetchPlaylistTracks(yt, id, cap = 500) {
205
- const tracks = [];
206
- let page = (await yt.music.getPlaylist(id));
207
- for (let fetches = 0; fetches < 6; fetches++) {
208
- for (const item of (page.items ?? page.contents ?? []).slice(0, cap - tracks.length)) {
209
- const track = toTrack(item);
210
- if (track)
211
- tracks.push(track);
212
- if (tracks.length >= cap)
213
- break;
214
- }
215
- if (tracks.length >= cap)
216
- break;
217
- if (!page.has_continuation || typeof page.getContinuation !== 'function')
218
- break;
219
- try {
220
- page = (await page.getContinuation());
221
- }
222
- catch (err) {
223
- logger.warn({ err }, '[music] playlist continuation failed');
224
- break;
225
- }
226
- }
227
- return tracks;
228
- }
229
- async function readLibrary(yt) {
230
- let lib;
231
- try {
232
- lib = (await yt.music.getLibrary());
233
- }
234
- catch (err) {
235
- if (!isParserVariantError(err))
236
- throw err;
237
- logger.warn('[music] library landing hit a layout variant, falling back to Liked playlist');
238
- try {
239
- const tracks = await fetchPlaylistTracks(yt, 'LM');
240
- return { tracks, playlists: [] };
241
- }
242
- catch (fallbackErr) {
243
- logger.warn({ err: fallbackErr }, '[music] liked playlist fallback failed');
244
- return { tracks: [], playlists: [] };
245
- }
246
- }
247
- const gridItems = [];
248
- for (const section of lib.contents ?? []) {
249
- const items = section.items ?? section.contents ?? [];
250
- gridItems.push(...items);
251
- }
252
- const playlists = [];
253
- const seen = new Set();
254
- for (const item of gridItems) {
255
- const n = item;
256
- const id = (typeof n['playlist_id'] === 'string' && n['playlist_id']) ||
257
- (typeof n['playlistId'] === 'string' && n['playlistId']) ||
258
- (typeof n['id'] === 'string' && n['id']) ||
259
- '';
260
- const title = textOf(n['title']);
261
- const subtitle = textOf(n['subtitle']);
262
- if (!id || !title || seen.has(id))
263
- continue;
264
- if (!/playlist|mix/i.test(subtitle) && title !== 'Liked Music')
265
- continue;
266
- seen.add(id);
267
- playlists.push({
268
- id,
269
- title,
270
- subtitle: subtitle || undefined,
271
- thumbnails: thumbsOf(n['thumbnails'] ?? n['thumbnail']),
272
- });
273
- }
274
- const tracks = [];
275
- const liked = playlists.find((p) => p.title === 'Liked Music');
276
- if (liked) {
277
- try {
278
- tracks.push(...(await fetchPlaylistTracks(yt, liked.id)));
279
- }
280
- catch (err) {
281
- logger.warn({ err }, '[music] liked playlist fetch failed');
282
- }
283
- }
284
- logger.info(`[music] library parsed tracks=${tracks.length} playlists=${playlists.length} gridItems=${gridItems.length}`);
285
- return { tracks, playlists };
286
- }
287
- const UPSTREAM_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
288
- const audioCache = new Map();
289
- const AUDIO_CACHE_MS = 5 * 60 * 1000;
290
46
  async function decipherWith(yt, videoId) {
291
47
  const info = await yt.music.getInfo(videoId);
292
48
  const streaming = info.streaming_data;
@@ -334,7 +90,6 @@ async function decipherAudio(videoId) {
334
90
  }
335
91
  return decipherWith(await getInnertube(), videoId);
336
92
  }
337
- const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
338
93
  export function registerMusic(app) {
339
94
  app.get('/api/music/stream', async (c) => {
340
95
  const videoId = String(c.req.query('videoId') || c.req.query('id') || '').trim();
@@ -353,61 +108,96 @@ export function registerMusic(app) {
353
108
  const videoId = String(c.req.query('videoId') || c.req.query('id') || '').trim();
354
109
  if (!videoId)
355
110
  return c.json({ error: 'videoId is required' }, 400);
111
+ const signal = c.req.raw.signal.aborted
112
+ ? c.req.raw.signal
113
+ : AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30000)]);
356
114
  try {
357
- let entry;
358
- try {
359
- entry = await decipherAudio(videoId);
360
- }
361
- catch {
362
- return c.json({ error: 'No stream available' }, 502);
363
- }
364
- const signal = c.req.raw.signal.aborted
365
- ? c.req.raw.signal
366
- : AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30000)]);
367
- if (c.req.raw.signal.aborted)
368
- return new Response(null, { status: 499 });
369
- let upstream;
370
- try {
371
- upstream = await fetch(entry.url, {
372
- headers: {
373
- 'User-Agent': UPSTREAM_UA,
374
- Referer: 'https://music.youtube.com/',
375
- Origin: 'https://music.youtube.com/',
376
- Range: c.req.header('range') ?? 'bytes=0-',
377
- },
378
- signal,
379
- });
380
- }
381
- catch (err) {
382
- if (c.req.raw.signal.aborted)
383
- return new Response(null, { status: 499 });
384
- logger.error({ err }, '[music] audio upstream failed');
385
- return c.json({ error: 'Upstream error' }, 502);
386
- }
387
- if (upstream.status !== 200 && upstream.status !== 206) {
115
+ for (let attempt = 0; attempt < 3; attempt++) {
116
+ if (attempt === 1)
117
+ umpSessions.delete(videoId);
118
+ if (attempt === 2) {
119
+ audioCache.delete(videoId);
120
+ umpSessions.delete(videoId);
121
+ resetPoTokens(videoId);
122
+ }
123
+ let entry;
388
124
  try {
389
- await upstream.body?.cancel();
125
+ entry = await decipherAudio(videoId);
390
126
  }
391
127
  catch {
392
- // ignore
128
+ return c.json({ error: 'No stream available' }, 502);
393
129
  }
394
- audioCache.delete(videoId);
395
- return c.json({ error: 'Upstream error' }, 502);
396
- }
397
- const headers = new Headers();
398
- for (const name of PASSTHROUGH_HEADERS) {
399
- const value = upstream.headers.get(name);
400
- if (value)
401
- headers.set(name === 'content-type' ? 'Content-Type' : name, value);
402
- }
403
- if (!headers.get('Content-Type'))
130
+ const clen = /[?&]clen=(\d+)/.exec(entry.url)?.[1];
131
+ if (clen && !audioTotalCache.has(videoId))
132
+ audioTotalCache.set(videoId, Number(clen));
133
+ const [rangeStart, rangeEnd] = boundWindow(c.req.header('range') ?? null, videoId);
134
+ const session = getUmpSession(videoId);
135
+ let pot = '';
136
+ try {
137
+ pot = await mintPoToken(videoId);
138
+ }
139
+ catch (err) {
140
+ logger.warn({ err }, '[music] PoToken mint failed, trying without attestation');
141
+ }
142
+ const upstreamUrl = `${entry.url}&cpn=${session.cpn}&cver=${UMP_CVER}` +
143
+ `&range=${rangeStart}-${rangeEnd}&rn=${session.rn}&rbuf=0` +
144
+ (pot ? `&pot=${encodeURIComponent(pot)}` : '') +
145
+ `&ump=1&srfvp=1`;
146
+ session.rn += 1;
147
+ let raw;
148
+ try {
149
+ const cookie = getMusicCookie();
150
+ const upstream = await fetch(upstreamUrl, {
151
+ method: 'POST',
152
+ headers: {
153
+ 'User-Agent': UPSTREAM_UA,
154
+ Referer: 'https://music.youtube.com/',
155
+ Origin: 'https://music.youtube.com/',
156
+ ...(cookie ? { Cookie: cookie } : {}),
157
+ },
158
+ body: 'x\x00',
159
+ signal,
160
+ });
161
+ if (upstream.status !== 200) {
162
+ try {
163
+ await upstream.body?.cancel();
164
+ }
165
+ catch {
166
+ // ignore
167
+ }
168
+ continue;
169
+ }
170
+ raw = Buffer.from(await upstream.arrayBuffer());
171
+ }
172
+ catch (err) {
173
+ if (c.req.raw.signal.aborted)
174
+ return new Response(null, { status: 499 });
175
+ logger.error({ err }, '[music] audio upstream failed');
176
+ continue;
177
+ }
178
+ const parsed = parseUmpResponse(raw, session.carry);
179
+ session.carry = parsed.carry;
180
+ if (parsed.status === 3) {
181
+ logger.warn({ videoId }, '[music] stream protection blocked, refreshing token');
182
+ resetPoTokens(videoId);
183
+ umpSessions.delete(videoId);
184
+ continue;
185
+ }
186
+ if (parsed.media.length === 0)
187
+ continue;
188
+ const total = audioTotalCache.get(videoId);
189
+ const headers = new Headers();
404
190
  headers.set('Content-Type', entry.mimeType || 'audio/webm');
405
- headers.set('Accept-Ranges', 'bytes');
406
- headers.set('Access-Control-Allow-Origin', '*');
407
- if (!upstream.body) {
408
- return c.json({ error: 'Upstream error' }, 502);
191
+ headers.set('Content-Length', String(parsed.media.length));
192
+ if (total) {
193
+ headers.set('Content-Range', `bytes ${rangeStart}-${rangeStart + parsed.media.length - 1}/${total}`);
194
+ }
195
+ headers.set('Accept-Ranges', 'bytes');
196
+ headers.set('Access-Control-Allow-Origin', '*');
197
+ return new Response(parsed.media, { status: 206, headers });
409
198
  }
410
- return new Response(upstream.body, { status: upstream.status, headers });
199
+ logger.warn({ videoId }, '[music] audio upstream gave no media after retries');
200
+ return c.json({ error: 'Upstream error' }, 502);
411
201
  }
412
202
  catch (err) {
413
203
  if (c.req.raw.signal.aborted)