@serifpersia/dango 3.4.3 → 3.4.5

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 (86) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-Chlfp7Cm.js → AnimeCard-I3S6qw9d.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-C1XZcEzj.js → AnimeInfo-CLU2NSBR.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-BvNYYbe1.js → AnimeInfoPage-4Mw9ArDy.js} +2 -2
  5. package/client/dist/assets/{Asmr-_jVaYDfT.js → Asmr-DN9XBc5Y.js} +1 -1
  6. package/client/dist/assets/{AsmrResetProgressModal-ngn9hdA0.js → AsmrResetProgressModal-DyU1Z2Gw.js} +1 -1
  7. package/client/dist/assets/{AvSyncCalibrator-Rcv7aubm.js → AvSyncCalibrator-DxL0mr_4.js} +1 -1
  8. package/client/dist/assets/{Home-CEbLBbmD.js → Home-CeTeJ-gM.js} +1 -1
  9. package/client/dist/assets/{Insights-Bia4rfp0.js → Insights-i34-qV4C.js} +1 -1
  10. package/client/dist/assets/{LibraryListPage-BVHcMPqZ.js → LibraryListPage-DE-ASOX2.js} +1 -1
  11. package/client/dist/assets/{ListeningList-DpRNQKk7.js → ListeningList-DrNce5ll.js} +1 -1
  12. package/client/dist/assets/{Manga-wHddjSTh.js → Manga-DOrf7Fk6.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-CVz8z5wn.js → MangaInfoPage-CAZMLMGw.js} +1 -1
  14. package/client/dist/assets/{MangaReadPage-CC3cgtIZ.js → MangaReadPage-DQVEo8_F.js} +1 -1
  15. package/client/dist/assets/{MangaResetProgressModal-DD7a0log.js → MangaResetProgressModal-DOjpLAMp.js} +1 -1
  16. package/client/dist/assets/{Mature-D8uFpIDj.js → Mature-D2xb9EGB.js} +1 -1
  17. package/client/dist/assets/{MediaCard-6Yy_-Whn.js → MediaCard-CKUEcFTd.js} +1 -1
  18. package/client/dist/assets/{Music-BgM0bcAU.js → Music-w_a34r7r.js} +1 -1
  19. package/client/dist/assets/{OptionListMenu-C9saW22f.js → OptionListMenu-DxZHBMIZ.js} +1 -1
  20. package/client/dist/assets/{Player-EwAWFxqQ.js → Player-B8YHeATv.js} +2 -2
  21. package/client/dist/assets/{PlayerSettings-BYqhlu3F.js → PlayerSettings-Ddlg-MvW.js} +1 -1
  22. package/client/dist/assets/{QueueOptionsButton-BJhkcl2X.js → QueueOptionsButton-BMvAC6Ut.js} +1 -1
  23. package/client/dist/assets/{QueueRail-BLfkt_s5.js → QueueRail-CAoGJvHP.js} +1 -1
  24. package/client/dist/assets/{Radio-D2Er2JEU.js → Radio-6O7rAUv1.js} +1 -1
  25. package/client/dist/assets/{ReadingList-DWOnwYO1.js → ReadingList-k3g8oHvs.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-HUj5zATK.js → ResetProgressModal-vFC6N-Qw.js} +1 -1
  27. package/client/dist/assets/{Search-CzD9J9ip.js → Search-Bh9Y6nIJ.js} +1 -1
  28. package/client/dist/assets/{Settings-BgUothvZ.js → Settings-DRumujfh.js} +1 -1
  29. package/client/dist/assets/{SkeletonGrid-C7Gj9ODL.js → SkeletonGrid-BtzUnQE0.js} +1 -1
  30. package/client/dist/assets/{Trackers-BoC2z0Nd.js → Trackers-D0nqy8wu.js} +1 -1
  31. package/client/dist/assets/{Tv-Q8TIMWaP.js → Tv-DVebLMNS.js} +1 -1
  32. package/client/dist/assets/{TvInfo-C6JoNbev.js → TvInfo-BkECbOKN.js} +1 -1
  33. package/client/dist/assets/{TvPopup-BArrygql.js → TvPopup-D8WWY6o4.js} +1 -1
  34. package/client/dist/assets/{TvSearch-BsoJ7WZp.js → TvSearch-kWPep3gL.js} +1 -1
  35. package/client/dist/assets/{TvWatchlist-BFmAd2fE.js → TvWatchlist-dD4emIHx.js} +1 -1
  36. package/client/dist/assets/{Watchlist-DG5Y--YA.js → Watchlist-cWxw38nU.js} +1 -1
  37. package/client/dist/assets/{hls-QeZPjik8.js → hls-uG_Y3i5s.js} +1 -1
  38. package/client/dist/assets/{index-Rm4h42Uz.js → index-BWIWE3Qy.js} +2 -2
  39. package/client/dist/assets/{useAnimeInfoData-B9O3Rr_u.js → useAnimeInfoData-DjPBg24u.js} +1 -1
  40. package/client/dist/assets/{useAsmr-Da7AVA9e.js → useAsmr-DQyobNQX.js} +1 -1
  41. package/client/dist/assets/{useAsmrPopup-B_S5s4q3.js → useAsmrPopup-CDoroMBD.js} +1 -1
  42. package/client/dist/assets/{useMangaLibrary-COpztbMg.js → useMangaLibrary-C6L8mrnS.js} +1 -1
  43. package/client/dist/assets/{useMangaPopup-BJeHmEpe.js → useMangaPopup-BSuMa_vA.js} +1 -1
  44. package/client/dist/assets/{useProviders-BWRYjB77.js → useProviders-XIdvDof6.js} +1 -1
  45. package/client/dist/assets/{useTv-DtiSJ756.js → useTv-DzSYasru.js} +1 -1
  46. package/client/dist/assets/{useTvLibrary-DFfgk9YS.js → useTvLibrary-BnbHS-gg.js} +1 -1
  47. package/client/dist/index.html +1 -1
  48. package/package.json +4 -2
  49. package/server/dist/app-hono.js +2 -0
  50. package/server/dist/hono/asmr.js +10 -1
  51. package/server/dist/hono/music.js +122 -332
  52. package/server/dist/hono/proxy.js +3 -24
  53. package/server/dist/hono/tv.js +3 -56
  54. package/server/dist/lib/mal.js +4 -3
  55. package/server/dist/lib/potoken.js +135 -0
  56. package/server/dist/lib/tracker/manga-sync.service.js +3 -22
  57. package/server/dist/lib/tracker/sync-state.js +21 -0
  58. package/server/dist/lib/tracker/sync.service.js +3 -22
  59. package/server/dist/lib/ump.js +106 -0
  60. package/server/dist/providers/remote-loader.js +2 -1
  61. package/server/dist/repositories/asmr.repository.js +4 -53
  62. package/server/dist/repositories/library.repository.js +46 -0
  63. package/server/dist/repositories/manga.repository.js +3 -52
  64. package/server/dist/repositories/settings.repository.js +8 -0
  65. package/server/dist/repositories/tv.repository.js +3 -46
  66. package/server/dist/server.js +5 -1
  67. package/server/dist/sync.js +3 -5
  68. package/server/dist/trpc/routers/asmr-library.js +2 -12
  69. package/server/dist/trpc/routers/asmr-progress.js +10 -22
  70. package/server/dist/trpc/routers/data.js +17 -36
  71. package/server/dist/trpc/routers/discord.js +1 -8
  72. package/server/dist/trpc/routers/insights.js +15 -16
  73. package/server/dist/trpc/routers/manga-library.js +5 -22
  74. package/server/dist/trpc/routers/manga-progress.js +8 -20
  75. package/server/dist/trpc/routers/music.js +6 -11
  76. package/server/dist/trpc/routers/queue.js +1 -1
  77. package/server/dist/trpc/routers/tracker.js +1 -7
  78. package/server/dist/trpc/routers/tv-library.js +2 -12
  79. package/server/dist/trpc/routers/tv-progress.js +16 -28
  80. package/server/dist/trpc/routers/tv.js +5 -21
  81. package/server/dist/trpc/routers/watchlist.js +1 -8
  82. package/server/dist/trpc/validation.js +35 -0
  83. package/server/dist/utils/db-utils.js +8 -0
  84. package/server/dist/utils/http.utils.js +12 -0
  85. package/server/dist/utils/machine-id.js +1 -2
  86. package/server/package.json +4 -1
@@ -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)
@@ -6,7 +6,7 @@ import fs from 'fs';
6
6
  import logger from '../logger.js';
7
7
  import { buildCfClearanceCookie, sanitizeCfClearance } from '../utils/cookie.utils.js';
8
8
  import { isSafeExternalUrl } from '../utils/security.utils.js';
9
- import { fetchWithRetry } from '../utils/http.utils.js';
9
+ import { fetchWithRetry, linkAbort, setProxyHeaders } from '../utils/http.utils.js';
10
10
  import { MEGAPLAY_ORIGIN, isNexabloomMasterUrl, signNexabloomMasterUrl, } from '../utils/megaplay.utils.js';
11
11
  const proxyCache = new AppCache({ ttlSeconds: 30, maxKeys: 500 });
12
12
  function assTimeToVtt(value) {
@@ -299,9 +299,6 @@ function placeholderSvg() {
299
299
  return null;
300
300
  }
301
301
  const PROXY_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0';
302
- function linkAbort(signal, abort) {
303
- signal.addEventListener('abort', () => abort.abort(), { once: true });
304
- }
305
302
  export function registerProxy(app) {
306
303
  app.get('/api/proxy', async (c) => {
307
304
  const url = c.req.query('url');
@@ -463,16 +460,7 @@ export function registerProxy(app) {
463
460
  const isVtt = urlStr.endsWith('.vtt') || urlStr.includes('.vtt?');
464
461
  const outHeaders = new Headers();
465
462
  outHeaders.set('Content-Type', isVtt ? 'text/vtt; charset=utf-8' : ct || 'application/octet-stream');
466
- const cl = resp.headers['content-length'];
467
- if (cl)
468
- outHeaders.set('Content-Length', cl);
469
- const cr = resp.headers['content-range'];
470
- if (cr)
471
- outHeaders.set('Content-Range', cr);
472
- const ar = resp.headers['accept-ranges'];
473
- if (ar)
474
- outHeaders.set('Accept-Ranges', ar);
475
- outHeaders.set('Access-Control-Allow-Origin', '*');
463
+ setProxyHeaders(outHeaders, (name) => resp.headers[name]);
476
464
  return new Response(resp.body, {
477
465
  status: resp.statusCode,
478
466
  headers: outHeaders,
@@ -516,16 +504,7 @@ export function registerProxy(app) {
516
504
  const ct = upstream.headers.get('content-type');
517
505
  const outHeaders = new Headers();
518
506
  outHeaders.set('Content-Type', isVtt ? 'text/vtt; charset=utf-8' : ct || 'application/octet-stream');
519
- const cl = upstream.headers.get('content-length');
520
- if (cl)
521
- outHeaders.set('Content-Length', cl);
522
- const cr = upstream.headers.get('content-range');
523
- if (cr)
524
- outHeaders.set('Content-Range', cr);
525
- const ar = upstream.headers.get('accept-ranges');
526
- if (ar)
527
- outHeaders.set('Accept-Ranges', ar);
528
- outHeaders.set('Access-Control-Allow-Origin', '*');
507
+ setProxyHeaders(outHeaders, (name) => upstream.headers.get(name));
529
508
  if (!upstream.body) {
530
509
  return c.text('Upstream error', 502);
531
510
  }
@@ -1,34 +1,7 @@
1
1
  import logger from '../logger.js';
2
- import { parseJsonBody } from '../utils/http.utils.js';
3
- import { getTmdbKey, TMDB_BASE, TMDB_IMAGE } from '../lib/tmdb.js';
2
+ import { linkAbort, setProxyHeaders } from '../utils/http.utils.js';
4
3
  import { URL } from 'url';
5
4
  import { isSafeExternalUrl } from '../utils/security.utils.js';
6
- async function resolveTvMeta(mediaType, numericTmdbId, partial) {
7
- const meta = { ...partial };
8
- if (meta.title && meta.imdbId && meta.year)
9
- return meta;
10
- try {
11
- const tmdbKey = await getTmdbKey();
12
- if (tmdbKey) {
13
- const tmdbRes = await fetch(`${TMDB_BASE}/${mediaType}/${numericTmdbId}?api_key=${tmdbKey}&append_to_response=external_ids`, { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: AbortSignal.timeout(4000) });
14
- if (tmdbRes.ok) {
15
- const d = await parseJsonBody(tmdbRes);
16
- if (!meta.title)
17
- meta.title = d.title || d.name || '';
18
- if (!meta.year)
19
- meta.year = (d.release_date || d.first_air_date || '').split('-')[0] || '';
20
- if (!meta.imdbId)
21
- meta.imdbId = d.external_ids?.imdb_id || d.imdb_id || '';
22
- if (mediaType === 'tv' && d.number_of_seasons)
23
- meta.totalSeasons = String(d.number_of_seasons);
24
- }
25
- }
26
- }
27
- catch {
28
- // ignore
29
- }
30
- return meta;
31
- }
32
5
  function proxiedMediaUrl(targetUrl, refererStr) {
33
6
  return `/api/tv/stream-proxy?url=${encodeURIComponent(targetUrl)}&referer=${encodeURIComponent(refererStr)}`;
34
7
  }
@@ -59,20 +32,6 @@ export function isPlaylistBody(body) {
59
32
  .trimStart()
60
33
  .startsWith('#EXTM3U'));
61
34
  }
62
- function toSearchResult(item, type) {
63
- return {
64
- id: item.id,
65
- title: item.title || item.name || '',
66
- year: (item.release_date || item.first_air_date || '').split('-')[0],
67
- type,
68
- image: item.poster_path ? `${TMDB_IMAGE}/w500${item.poster_path}` : '',
69
- backdrop: item.backdrop_path ? `${TMDB_IMAGE}/w780${item.backdrop_path}` : '',
70
- overview: item.overview || '',
71
- vote_average: item.vote_average || 0,
72
- adult: item.adult === true,
73
- genre_ids: item.genre_ids || [],
74
- };
75
- }
76
35
  export function registerTv(app, getApiCache, getTvProvider) {
77
36
  app.get('/api/tv/embed/:provider/:type/:tmdbId', async (c) => {
78
37
  const provider = c.req.param('provider');
@@ -117,10 +76,7 @@ export function registerTv(app, getApiCache, getTvProvider) {
117
76
  }
118
77
  const abort = new AbortController();
119
78
  const timeout = setTimeout(() => abort.abort(), 30000);
120
- c.req.raw.signal.addEventListener('abort', () => {
121
- clearTimeout(timeout);
122
- abort.abort();
123
- }, { once: true });
79
+ linkAbort(c.req.raw.signal, abort);
124
80
  try {
125
81
  const headers = {
126
82
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@@ -137,18 +93,9 @@ export function registerTv(app, getApiCache, getTvProvider) {
137
93
  return c.text('Upstream error', (status || 502));
138
94
  }
139
95
  const contentType = fetchResp.headers.get('content-type') || 'application/octet-stream';
140
- const contentLength = fetchResp.headers.get('content-length');
141
- const contentRange = fetchResp.headers.get('content-range');
142
- const acceptRanges = fetchResp.headers.get('accept-ranges');
143
96
  const outHeaders = new Headers();
144
97
  outHeaders.set('Content-Type', contentType);
145
- if (contentLength)
146
- outHeaders.set('Content-Length', contentLength);
147
- if (contentRange)
148
- outHeaders.set('Content-Range', contentRange);
149
- if (acceptRanges)
150
- outHeaders.set('Accept-Ranges', acceptRanges);
151
- outHeaders.set('Access-Control-Allow-Origin', '*');
98
+ setProxyHeaders(outHeaders, (name) => fetchResp.headers.get(name));
152
99
  outHeaders.set('Connection', 'keep-alive');
153
100
  if (urlStr.includes('.m3u8') || /mpegurl|m3u8/i.test(contentType)) {
154
101
  const body = await fetchResp.text();
@@ -603,9 +603,10 @@ export async function enrichCardTitles(store, items) {
603
603
  export async function malSearchMedia(store, options = {}, enrichTitles = true) {
604
604
  const { query, page = 1, format, status, season, seasonYear, genre, genre_in, genre_not_in, averageScore_greater, episodes_greater, isAdult, sort, } = options;
605
605
  const params = new URLSearchParams();
606
- const hasQuery = !!query?.trim();
607
- if (hasQuery)
608
- params.set('q', query.trim());
606
+ const trimmedQuery = query?.trim();
607
+ const hasQuery = !!trimmedQuery;
608
+ if (trimmedQuery)
609
+ params.set('q', trimmedQuery);
609
610
  params.set('cat', 'anime');
610
611
  if (page > 1)
611
612
  params.set('show', String((page - 1) * 50));