@serifpersia/dango 3.3.8 → 3.4.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.
Files changed (180) hide show
  1. package/README.md +1 -1
  2. package/client/dist/assets/{AnimeCard-D4qo4289.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-BdRRm4So.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-DeJNAKMR.js → AnimeInfoPage-BxbA_7AV.js} +2 -2
  5. package/client/dist/assets/Asmr--Zc8G9zv.js +2 -0
  6. package/client/dist/assets/AsmrResetProgressModal-CKYm-8cp.js +1 -0
  7. package/client/dist/assets/AvSyncCalibrator-3yjZS7s6.js +10 -0
  8. package/client/dist/assets/Home-rrhOGQcg.js +1 -0
  9. package/client/dist/assets/Insights-Bay90Yeg.js +1 -0
  10. package/client/dist/assets/{LibraryListPage-D3hTqTRU.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-BgWsfu40.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Dn-CYins.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-EmQoK7Bv.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-Dian37PQ.js → MangaResetProgressModal-QjZyIAjl.js} +1 -1
  16. package/client/dist/assets/Mature-CHt_-Hz5.js +1 -0
  17. package/client/dist/assets/MediaCard-B5584Cq9.js +1 -0
  18. package/client/dist/assets/Music-HeLrEZ2d.js +1 -0
  19. package/client/dist/assets/OptionListMenu-CJ90CdwI.js +1 -0
  20. package/client/dist/assets/Player-BYtEax_z.js +56 -0
  21. package/client/dist/assets/PlayerSettings-4-cUAY4B.js +1 -0
  22. package/client/dist/assets/QueueOptionsButton-Ba0dg_82.js +1 -0
  23. package/client/dist/assets/{QueueRail-B7DeSFoA.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BT8kUjCB.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DWeiO-hs.js → ResetProgressModal-CcStlbhP.js} +1 -1
  27. package/client/dist/assets/Search-C2wrWOdy.js +1 -0
  28. package/client/dist/assets/Settings-CluUYs8U.js +21 -0
  29. package/client/dist/assets/{SkeletonGrid-B5loz7ii.js → SkeletonGrid-CLEPY9nP.js} +1 -1
  30. package/client/dist/assets/Trackers-CTGPxs0V.js +2 -0
  31. package/client/dist/assets/Tv-BwHlDdi8.js +4 -0
  32. package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
  33. package/client/dist/assets/TvInfo-CXnrLekr.js +1 -0
  34. package/client/dist/assets/TvPopup-BekfiNcX.js +1 -0
  35. package/client/dist/assets/{TvSearch-BYwCf5kO.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-BiJU6pKT.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-C11FOWHj.js → hls-Boje3IOE.js} +1 -1
  39. package/client/dist/assets/index-BXwhFWB_.js +178 -0
  40. package/client/dist/assets/useAnimeInfoData-DJ7A4B4T.js +1 -0
  41. package/client/dist/assets/useAsmr-DIwIsJXF.js +1 -0
  42. package/client/dist/assets/{useAsmrPopup-cT_Uu41e.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-PnmSctHu.js → useMangaPopup-B8nnpvyi.js} +1 -1
  45. package/client/dist/assets/useProviders-B5Qr4I1y.js +1 -0
  46. package/client/dist/assets/useTv-Dm8MCYlL.js +1 -0
  47. package/client/dist/assets/useTvLibrary-B7jEk2Q7.js +1 -0
  48. package/client/dist/assets/{vendor-query-BXRECSI6.js → vendor-query-BUTye89G.js} +1 -1
  49. package/client/dist/index.html +2 -2
  50. package/orchestrator.js +1 -2
  51. package/package.json +2 -6
  52. package/server/dist/app-auth.js +9 -29
  53. package/server/dist/app-hono.js +621 -0
  54. package/server/dist/config.js +0 -1
  55. package/server/dist/db/drizzle.js +32 -0
  56. package/server/dist/db/schema-anime.js +64 -0
  57. package/server/dist/db/schema-asmr.js +25 -0
  58. package/server/dist/db/schema-manga.js +31 -0
  59. package/server/dist/db/schema-tv.js +33 -0
  60. package/server/dist/db.js +75 -4
  61. package/server/dist/discord-gateway.js +1 -1
  62. package/server/dist/google.js +0 -7
  63. package/server/dist/hono/asmr.js +1 -0
  64. package/server/dist/hono/auth.js +56 -0
  65. package/server/dist/hono/music.js +419 -0
  66. package/server/dist/{controllers/proxy.controller.js → hono/proxy.js} +390 -394
  67. package/server/dist/hono/tv.js +197 -0
  68. package/server/dist/hono/watchlist.js +733 -0
  69. package/server/dist/lib/anilist.js +3 -3
  70. package/server/dist/lib/insights.js +185 -0
  71. package/server/dist/lib/ipc.js +1 -2
  72. package/server/dist/lib/kitsu.js +1 -28
  73. package/server/dist/lib/mal-import.js +291 -0
  74. package/server/dist/lib/migration.js +20 -34
  75. package/server/dist/lib/offline-db.js +36 -36
  76. package/server/dist/lib/tracker/manga-sync.service.js +21 -21
  77. package/server/dist/lib/tracker/sync.service.js +20 -20
  78. package/server/dist/lib/translate.js +79 -0
  79. package/server/dist/lib/ytmusic.js +15 -4
  80. package/server/dist/providers/title-matching.js +0 -1
  81. package/server/dist/repositories/asmr.repository.js +90 -87
  82. package/server/dist/repositories/insights.repository.js +24 -13
  83. package/server/dist/repositories/library.repository.js +33 -93
  84. package/server/dist/repositories/manga.repository.js +108 -116
  85. package/server/dist/repositories/notifications.repository.js +50 -31
  86. package/server/dist/repositories/queue.repository.js +36 -25
  87. package/server/dist/repositories/settings.repository.js +18 -8
  88. package/server/dist/repositories/shows-meta.repository.js +54 -41
  89. package/server/dist/repositories/temp-show-ids.repository.js +43 -31
  90. package/server/dist/repositories/tv.repository.js +98 -115
  91. package/server/dist/repositories/watched-episodes.repository.js +52 -20
  92. package/server/dist/repositories/watchlist.repository.js +36 -48
  93. package/server/dist/server.js +59 -151
  94. package/server/dist/sync.js +41 -4
  95. package/server/dist/trpc/context.js +7 -0
  96. package/server/dist/trpc/errors.js +21 -0
  97. package/server/dist/trpc/index.js +19 -0
  98. package/server/dist/trpc/router.js +49 -0
  99. package/server/dist/trpc/routers/asmr-library.js +122 -0
  100. package/server/dist/trpc/routers/asmr-progress.js +133 -0
  101. package/server/dist/trpc/routers/asmr.js +87 -0
  102. package/server/dist/trpc/routers/auth.js +247 -0
  103. package/server/dist/trpc/routers/continue-watching.js +83 -0
  104. package/server/dist/trpc/routers/data.js +1039 -0
  105. package/server/dist/trpc/routers/discord.js +288 -0
  106. package/server/dist/trpc/routers/discovery.js +15 -0
  107. package/server/dist/trpc/routers/insights.js +45 -0
  108. package/server/dist/trpc/routers/manga-library.js +280 -0
  109. package/server/dist/trpc/routers/manga-progress.js +181 -0
  110. package/server/dist/trpc/routers/manga.js +201 -0
  111. package/server/dist/{controllers/music.controller.js → trpc/routers/music.js} +235 -295
  112. package/server/dist/trpc/routers/notifications.js +80 -0
  113. package/server/dist/trpc/routers/progress.js +15 -0
  114. package/server/dist/trpc/routers/queue.js +95 -0
  115. package/server/dist/{routes/radio.routes.js → trpc/routers/radio.js} +30 -23
  116. package/server/dist/trpc/routers/settings.js +105 -0
  117. package/server/dist/trpc/routers/tracker.js +219 -0
  118. package/server/dist/trpc/routers/tv-library.js +145 -0
  119. package/server/dist/trpc/routers/tv-progress.js +171 -0
  120. package/server/dist/trpc/routers/tv.js +565 -0
  121. package/server/dist/trpc/routers/watchlist.js +564 -0
  122. package/server/dist/trpc/validation.js +159 -0
  123. package/server/dist/utils/security.utils.js +0 -18
  124. package/server/package.json +5 -10
  125. package/client/dist/assets/Asmr-D0EGRYsv.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-Gs0GmVaT.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-D8hqarVB.js +0 -10
  128. package/client/dist/assets/Home-BnBzjFkW.js +0 -1
  129. package/client/dist/assets/Insights-i9QgY3nE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-xJB8K2YU.js +0 -1
  131. package/client/dist/assets/Mature-DQtXKOXc.js +0 -1
  132. package/client/dist/assets/MediaCard-DpKoY-1j.js +0 -1
  133. package/client/dist/assets/Music-BP43Zqmq.js +0 -1
  134. package/client/dist/assets/OptionListMenu-Bj9ebsW3.js +0 -1
  135. package/client/dist/assets/Player-O2Cy2GF1.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DYZ272Yp.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-lv26-6jp.js +0 -1
  138. package/client/dist/assets/Radio-qQBYnVfY.js +0 -1
  139. package/client/dist/assets/Search-BkvPYN33.js +0 -1
  140. package/client/dist/assets/Settings-CyZEBF6S.js +0 -21
  141. package/client/dist/assets/Trackers-JK-aXBYC.js +0 -2
  142. package/client/dist/assets/Tv-BwXWJxMy.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-Dzyq6ucH.js +0 -1
  145. package/client/dist/assets/TvPopup-PlCU30FO.js +0 -1
  146. package/client/dist/assets/Watchlist-BOEeGXH9.js +0 -1
  147. package/client/dist/assets/index-CtoKRNLc.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-kr3WOIWH.js +0 -1
  149. package/client/dist/assets/useAsmr-CB9THf0m.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +0 -1
  151. package/client/dist/assets/useProviders-B3VB7A9M.js +0 -1
  152. package/client/dist/assets/useTv-BTUKMH7Q.js +0 -1
  153. package/client/dist/assets/useTvLibrary-C0sX-ilZ.js +0 -1
  154. package/server/dist/controllers/asmr-library.controller.js +0 -288
  155. package/server/dist/controllers/auth.controller.js +0 -225
  156. package/server/dist/controllers/data.controller.js +0 -767
  157. package/server/dist/controllers/insights.controller.js +0 -203
  158. package/server/dist/controllers/manga-library.controller.js +0 -350
  159. package/server/dist/controllers/settings.controller.js +0 -609
  160. package/server/dist/controllers/tv-library.controller.js +0 -314
  161. package/server/dist/controllers/watchlist.controller.js +0 -1069
  162. package/server/dist/request-logger.js +0 -16
  163. package/server/dist/routes/asmr-library.routes.js +0 -23
  164. package/server/dist/routes/asmr.routes.js +0 -81
  165. package/server/dist/routes/auth.routes.js +0 -25
  166. package/server/dist/routes/data.routes.js +0 -47
  167. package/server/dist/routes/discord-gateway.routes.js +0 -51
  168. package/server/dist/routes/insights.routes.js +0 -35
  169. package/server/dist/routes/lan-auth.routes.js +0 -46
  170. package/server/dist/routes/manga-library.routes.js +0 -25
  171. package/server/dist/routes/manga.routes.js +0 -141
  172. package/server/dist/routes/music.routes.js +0 -20
  173. package/server/dist/routes/providers.routes.js +0 -17
  174. package/server/dist/routes/proxy.routes.js +0 -12
  175. package/server/dist/routes/settings.routes.js +0 -24
  176. package/server/dist/routes/tracker.routes.js +0 -287
  177. package/server/dist/routes/translate.routes.js +0 -88
  178. package/server/dist/routes/tv-library.routes.js +0 -23
  179. package/server/dist/routes/tv.routes.js +0 -629
  180. package/server/dist/routes/watchlist.routes.js +0 -341
@@ -1,6 +1,8 @@
1
- import { Readable } from 'node:stream';
2
- import logger from '../logger.js';
3
- import { getInnertube, getAuthedInnertube, saveMusicCookie, getMusicAuthStatus, signOutMusic, isParserVariantError, } from '../lib/ytmusic.js';
1
+ import { TRPCError } from '@trpc/server';
2
+ import { protectedProcedure, router } from '../index.js';
3
+ import { defineSchema, optStr, reqObj } from '../validation.js';
4
+ import logger from '../../logger.js';
5
+ import { getInnertube, getAuthedInnertube, refreshAuthedInnertube, saveMusicCookie, getMusicAuthStatus, signOutMusic, isParserVariantError, } from '../../lib/ytmusic.js';
4
6
  const NAME_SUFFIXES = [
5
7
  /\s*(- topic)$/i,
6
8
  /\s*vevo$/i,
@@ -13,7 +15,7 @@ const NAME_SUFFIXES = [
13
15
  /\s*[(|[](live)[)|\]]$/i,
14
16
  /\s*[(|[]4K\s*?(?:upgrade)?[)|\]]$/i,
15
17
  ];
16
- export function cleanupMusicName(name) {
18
+ function cleanupMusicName(name) {
17
19
  if (!name)
18
20
  return name;
19
21
  let out = name;
@@ -93,9 +95,6 @@ function nodeType(node) {
93
95
  return n.type;
94
96
  return n?.constructor?.name ?? '';
95
97
  }
96
- /** Map a PlaylistPanel up-next entry (video or wrapper) to a MusicTrack.
97
- * When trustLike is set (signed-in session), also read the current like
98
- * state from YTM's own toggle (UNFAVORITE icon = currently liked). */
99
98
  function upnextToTrack(node, trustLike = false) {
100
99
  const kind = nodeType(node);
101
100
  if (kind === 'AutomixPreviewVideo')
@@ -151,16 +150,25 @@ function upnextToTrack(node, trustLike = false) {
151
150
  ...(liked !== null ? { liked } : {}),
152
151
  };
153
152
  }
153
+ function parseUpnext(panel, trustLike) {
154
+ const tracks = [];
155
+ const contents = panel.contents ?? [];
156
+ for (const item of contents) {
157
+ const track = upnextToTrack(item, trustLike);
158
+ if (track)
159
+ tracks.push(track);
160
+ if (tracks.length >= 50)
161
+ break;
162
+ }
163
+ const playlistId = panel.playlist_id ?? null;
164
+ return { tracks, playlistId };
165
+ }
154
166
  function asMenuItems(menu) {
155
167
  if (!menu || typeof menu !== 'object')
156
168
  return [];
157
169
  const items = menu.items;
158
170
  return Array.isArray(items) ? items : [];
159
171
  }
160
- /** Find the YTM "liked songs" toggle for a track from its up-next menu.
161
- * NOTE: default/toggled swap roles with the current state — when the song is
162
- * already liked, default removes (status INDIFFERENT) and toggled re-adds
163
- * (status LIKE). The payload `status` is the only reliable signal. */
164
172
  function findLikeToggle(item, videoId) {
165
173
  const result = { like: null, unlike: null };
166
174
  const kind = nodeType(item);
@@ -202,6 +210,8 @@ async function fetchPlaylistTracks(yt, id, cap = 500) {
202
210
  const track = toTrack(item);
203
211
  if (track)
204
212
  tracks.push(track);
213
+ if (tracks.length >= cap)
214
+ break;
205
215
  }
206
216
  if (tracks.length >= cap)
207
217
  break;
@@ -275,107 +285,116 @@ async function readLibrary(yt) {
275
285
  logger.info(`[music] library parsed tracks=${tracks.length} playlists=${playlists.length} gridItems=${gridItems.length}`);
276
286
  return { tracks, playlists };
277
287
  }
278
- 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';
279
- const audioCache = new Map();
280
- const AUDIO_CACHE_MS = 5 * 60 * 1000;
281
- async function decipherAudio(videoId) {
282
- const cached = audioCache.get(videoId);
283
- if (cached && Date.now() - cached.fetchedAt < AUDIO_CACHE_MS)
284
- return cached;
285
- const authed = await getAuthedInnertube().catch(() => null);
286
- const yt = authed ?? (await getInnertube());
287
- const info = await yt.music.getInfo(videoId);
288
- const streaming = info.streaming_data;
289
- const formats = streaming?.adaptive_formats ?? [];
290
- const sorted = [...formats]
291
- .filter((f) => f.has_audio && !f.has_video)
292
- .sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
293
- const player = yt.session.player;
294
- for (const fmt of sorted) {
295
- try {
296
- const url = await fmt.decipher(player);
297
- if (url && url.startsWith('http')) {
298
- const entry = { url, mimeType: fmt.mime_type, fetchedAt: Date.now() };
299
- audioCache.set(videoId, entry);
300
- return entry;
301
- }
302
- }
303
- catch {
304
- // ignore
305
- }
306
- }
307
- throw new Error('No stream available');
288
+ function badRequest(message) {
289
+ return new TRPCError({ code: 'BAD_REQUEST', message });
308
290
  }
309
- const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
310
- export class MusicController {
311
- cache;
312
- constructor(cache) {
313
- this.cache = cache;
314
- }
315
- getAuthStatus = (_req, res) => {
316
- res.json(getMusicAuthStatus());
317
- };
318
- startAuth = async (req, res) => {
291
+ function unauthorized(message) {
292
+ return new TRPCError({ code: 'UNAUTHORIZED', message });
293
+ }
294
+ function failed(message) {
295
+ return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message });
296
+ }
297
+ const musicSearchInput = () => defineSchema((value) => {
298
+ const obj = reqObj(value);
299
+ const out = {};
300
+ const q = optStr(obj, 'q');
301
+ if (q !== undefined)
302
+ out.q = q;
303
+ return out;
304
+ });
305
+ const musicTrackInput = () => defineSchema((value) => {
306
+ const obj = reqObj(value);
307
+ const out = {};
308
+ const id = optStr(obj, 'id');
309
+ if (id !== undefined)
310
+ out.id = id;
311
+ return out;
312
+ });
313
+ const musicAuthStartInput = () => defineSchema((value) => {
314
+ const obj = reqObj(value);
315
+ const out = {};
316
+ const cookie = optStr(obj, 'cookie');
317
+ if (cookie !== undefined)
318
+ out.cookie = cookie;
319
+ return out;
320
+ });
321
+ const musicLikeInput = () => defineSchema((value) => {
322
+ const obj = reqObj(value);
323
+ const out = {};
324
+ const id = optStr(obj, 'id');
325
+ if (id !== undefined)
326
+ out.id = id;
327
+ if (typeof obj.like === 'boolean' || typeof obj.like === 'string')
328
+ out.like = obj.like;
329
+ return out;
330
+ });
331
+ function requireId(input) {
332
+ const id = String(input.id || '').trim();
333
+ if (!id)
334
+ throw badRequest('id is required');
335
+ return id;
336
+ }
337
+ export const musicRouter = router({
338
+ authStatus: protectedProcedure.query(() => {
339
+ return getMusicAuthStatus();
340
+ }),
341
+ authStart: protectedProcedure.input(musicAuthStartInput()).mutation(async ({ input }) => {
342
+ let cookie = String(input.cookie || '').trim();
343
+ if (!cookie)
344
+ throw badRequest('Cookie is required');
345
+ cookie = cookie
346
+ .replace(/^cookie\s*:\s*/i, '')
347
+ .replace(/^["']+|["']+$/g, '')
348
+ .trim();
349
+ if (cookie.includes('…') || cookie.includes('...')) {
350
+ throw badRequest(`Cookie looks truncated (${cookie.length} chars). In DevTools, right-click the Cookie header → Copy value (do not copy from the wrapped preview).`);
351
+ }
352
+ if (!cookie.includes('SID') && !cookie.includes('SAPISID')) {
353
+ throw badRequest('That does not look like a YouTube cookie at all. Copy the full Cookie request header from a "browse" call on music.youtube.com in DevTools → Network.');
354
+ }
319
355
  try {
320
- let cookie = String(req.body?.cookie || '').trim();
321
- if (!cookie)
322
- return res.status(400).json({ error: 'Cookie is required' });
323
- cookie = cookie
324
- .replace(/^cookie\s*:\s*/i, '')
325
- .replace(/^["']+|["']+$/g, '')
326
- .trim();
327
- if (cookie.includes('…') || cookie.includes('...')) {
328
- return res.status(400).json({
329
- error: `Cookie looks truncated (${cookie.length} chars). In DevTools, right-click the Cookie header → Copy value (do not copy from the wrapped preview).`,
330
- });
331
- }
332
- if (!cookie.includes('SID') && !cookie.includes('SAPISID')) {
333
- return res.status(400).json({
334
- error: 'That does not look like a YouTube cookie at all. Copy the full Cookie request header from a "browse" call on music.youtube.com in DevTools → Network.',
335
- });
336
- }
337
356
  await saveMusicCookie(cookie);
338
357
  const yt = await getAuthedInnertube();
339
358
  if (yt) {
340
359
  try {
341
360
  const lib = await readLibrary(yt);
342
- return res.json({
361
+ return {
343
362
  success: true,
344
363
  tracks: lib.tracks.length,
345
364
  playlists: lib.playlists.length,
346
- });
365
+ };
347
366
  }
348
367
  catch {
349
368
  // ignore
350
369
  }
351
370
  }
352
- res.json({ success: true, tracks: 0, playlists: 0 });
371
+ return { success: true, tracks: 0, playlists: 0 };
353
372
  }
354
373
  catch (err) {
374
+ if (err instanceof TRPCError)
375
+ throw err;
355
376
  logger.error({ err }, '[music] cookie sign-in failed');
356
- res.status(401).json({
357
- error: 'Cookie rejected by YouTube Music. Copy a fresh Cookie header from music.youtube.com.',
358
- });
377
+ throw unauthorized('Cookie rejected by YouTube Music. Copy a fresh Cookie header from music.youtube.com.');
359
378
  }
360
- };
361
- signOut = async (_req, res) => {
379
+ }),
380
+ signOut: protectedProcedure.mutation(async () => {
362
381
  try {
363
382
  await signOutMusic();
364
- res.json({ success: true });
383
+ return { success: true };
365
384
  }
366
385
  catch (err) {
367
386
  logger.error({ err }, '[music] sign-out failed');
368
- res.status(500).json({ error: 'Sign-out failed' });
387
+ throw failed('Sign-out failed');
369
388
  }
370
- };
371
- search = async (req, res) => {
372
- const q = String(req.query.q || '').trim();
389
+ }),
390
+ search: protectedProcedure.input(musicSearchInput()).query(async ({ ctx, input }) => {
391
+ const q = String(input.q || '').trim();
373
392
  if (!q)
374
- return res.json({ tracks: [] });
393
+ return { tracks: [] };
375
394
  const cacheKey = `music-search-${q.toLowerCase()}`;
376
- const cached = this.cache.get(cacheKey);
395
+ const cached = ctx.apiCache.get(cacheKey);
377
396
  if (cached)
378
- return res.json({ tracks: cached });
397
+ return { tracks: cached };
379
398
  try {
380
399
  const yt = await getInnertube();
381
400
  const result = await yt.music.search(q, { type: 'song' });
@@ -393,136 +412,26 @@ export class MusicController {
393
412
  if (tracks.length >= 25)
394
413
  break;
395
414
  }
396
- this.cache.set(cacheKey, tracks, 300);
397
- res.json({ tracks });
415
+ ctx.apiCache.set(cacheKey, tracks, 300);
416
+ return { tracks };
398
417
  }
399
418
  catch (err) {
400
419
  logger.error({ err }, '[music] search failed');
401
- res.json({ tracks: [] });
402
- }
403
- };
404
- stream = async (req, res) => {
405
- const videoId = String(req.query.videoId || req.query.id || '').trim();
406
- if (!videoId)
407
- return res.status(400).json({ error: 'videoId is required' });
408
- try {
409
- const entry = await decipherAudio(videoId);
410
- res.json({ url: entry.url, mimeType: entry.mimeType });
411
- }
412
- catch (err) {
413
- logger.error({ err }, '[music] stream failed');
414
- res.status(502).json({ error: 'Stream lookup failed' });
420
+ return { tracks: [] };
415
421
  }
416
- };
417
- audio = async (req, res) => {
418
- const videoId = String(req.query.videoId || req.query.id || '').trim();
419
- if (!videoId)
420
- return res.status(400).json({ error: 'videoId is required' });
421
- const aborter = new AbortController();
422
- res.on('close', () => {
423
- if (!res.writableEnded)
424
- aborter.abort();
425
- });
426
- try {
427
- let entry;
428
- try {
429
- entry = await decipherAudio(videoId);
430
- }
431
- catch {
432
- return res.status(502).json({ error: 'No stream available' });
433
- }
434
- const timeout = AbortSignal.timeout(30000);
435
- const signal = aborter.signal.aborted
436
- ? aborter.signal
437
- : AbortSignal.any([aborter.signal, timeout]);
438
- let upstream;
439
- try {
440
- upstream = await fetch(entry.url, {
441
- headers: {
442
- 'User-Agent': UPSTREAM_UA,
443
- Referer: 'https://music.youtube.com/',
444
- Origin: 'https://music.youtube.com/',
445
- Range: typeof req.headers.range === 'string' ? req.headers.range : 'bytes=0-',
446
- },
447
- signal,
448
- });
449
- }
450
- catch (err) {
451
- if (aborter.signal.aborted)
452
- return;
453
- logger.error({ err }, '[music] audio upstream failed');
454
- if (!res.headersSent)
455
- res.status(502).json({ error: 'Upstream error' });
456
- return;
457
- }
458
- if (upstream.status !== 200 && upstream.status !== 206) {
459
- try {
460
- await upstream.body?.cancel();
461
- }
462
- catch {
463
- // ignore
464
- }
465
- audioCache.delete(videoId);
466
- if (!res.headersSent)
467
- res.status(502).json({ error: 'Upstream error' });
468
- return;
469
- }
470
- res.status(upstream.status);
471
- for (const name of PASSTHROUGH_HEADERS) {
472
- const value = upstream.headers.get(name);
473
- if (value)
474
- res.set(name === 'content-type' ? 'Content-Type' : name, value);
475
- }
476
- if (!res.get('Content-Type'))
477
- res.set('Content-Type', entry.mimeType || 'audio/webm');
478
- res.set('Accept-Ranges', 'bytes');
479
- res.set('Access-Control-Allow-Origin', '*');
480
- if (!upstream.body) {
481
- if (!res.headersSent)
482
- res.status(502).json({ error: 'Upstream error' });
483
- return;
484
- }
485
- try {
486
- const nodeStream = Readable.fromWeb(upstream.body);
487
- nodeStream.on('error', () => {
488
- try {
489
- if (!res.writableEnded)
490
- res.destroy();
491
- }
492
- catch {
493
- // ignore
494
- }
495
- });
496
- nodeStream.pipe(res);
497
- }
498
- catch (err) {
499
- logger.error({ err }, '[music] audio pipe failed');
500
- if (!res.headersSent)
501
- res.status(502).json({ error: 'Upstream error' });
502
- }
503
- }
504
- catch (err) {
505
- if (aborter.signal.aborted)
506
- return;
507
- logger.error({ err }, '[music] audio failed');
508
- if (!res.headersSent)
509
- res.status(502).json({ error: 'Stream lookup failed' });
510
- }
511
- };
512
- track = async (req, res) => {
513
- const id = String(req.query.id || '').trim();
514
- if (!id)
515
- return res.status(400).json({ error: 'id is required', track: null });
422
+ }),
423
+ track: protectedProcedure.input(musicTrackInput()).query(async ({ ctx, input }) => {
424
+ const id = requireId(input);
516
425
  const cacheKey = `music-track-${id.toLowerCase()}`;
517
- const cached = this.cache.get(cacheKey);
426
+ const cached = ctx.apiCache.get(cacheKey);
518
427
  if (cached)
519
- return res.json({ track: cached });
428
+ return { track: cached };
520
429
  try {
521
430
  const yt = await getInnertube();
522
431
  const info = await yt.music.getInfo(id);
523
432
  const basic = info.basic_info;
524
433
  if (!basic?.title)
525
- return res.json({ track: null });
434
+ return { track: null };
526
435
  const durationSec = typeof basic.duration === 'number' && Number.isFinite(basic.duration)
527
436
  ? Math.max(Math.round(basic.duration), 0)
528
437
  : 0;
@@ -535,146 +444,165 @@ export class MusicController {
535
444
  : undefined,
536
445
  thumbnails: Array.isArray(basic.thumbnail) ? basic.thumbnail : [],
537
446
  };
538
- this.cache.set(cacheKey, track, 300);
539
- res.json({ track });
447
+ ctx.apiCache.set(cacheKey, track, 300);
448
+ return { track: track };
540
449
  }
541
450
  catch (err) {
542
451
  logger.error({ err }, '[music] track lookup failed');
543
- res.json({ track: null });
452
+ return { track: null };
544
453
  }
545
- };
546
- playlist = async (req, res) => {
547
- const id = String(req.query.id || '').trim();
548
- if (!id)
549
- return res.status(400).json({ error: 'id is required', tracks: [] });
550
- const yt = await getAuthedInnertube();
454
+ }),
455
+ playlist: protectedProcedure.input(musicTrackInput()).query(async ({ input }) => {
456
+ const id = requireId(input);
457
+ const yt = await getAuthedInnertube().catch(() => null);
551
458
  if (!yt) {
552
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [] });
459
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
553
460
  }
554
461
  try {
555
- res.json({ tracks: await fetchPlaylistTracks(yt, id) });
462
+ return { tracks: await fetchPlaylistTracks(yt, id) };
556
463
  }
557
464
  catch (err) {
558
- logger.warn({ err }, '[music] playlist failed, returning empty');
559
- res.json({ tracks: [] });
465
+ logger.warn({ err }, '[music] playlist failed, retrying with a fresh saved-cookie session');
466
+ const fresh = await refreshAuthedInnertube();
467
+ if (!fresh)
468
+ return { tracks: [] };
469
+ try {
470
+ return { tracks: await fetchPlaylistTracks(fresh, id) };
471
+ }
472
+ catch (retryErr) {
473
+ logger.warn({ err: retryErr }, '[music] playlist retry failed, returning empty');
474
+ return { tracks: [] };
475
+ }
560
476
  }
561
- };
562
- library = async (_req, res) => {
563
- const yt = await getAuthedInnertube();
477
+ }),
478
+ library: protectedProcedure.query(async () => {
479
+ const yt = await getAuthedInnertube().catch(() => null);
564
480
  if (!yt) {
565
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [], playlists: [] });
481
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
566
482
  }
567
483
  try {
568
- res.json(await readLibrary(yt));
484
+ const first = await readLibrary(yt);
485
+ if (first.tracks.length > 0 || first.playlists.length > 0)
486
+ return first;
487
+ logger.warn('[music] library came back empty, retrying with a fresh saved-cookie session');
569
488
  }
570
489
  catch (err) {
571
- logger.warn({ err }, '[music] library failed, returning empty');
572
- res.json({ tracks: [], playlists: [] });
490
+ logger.warn({ err }, '[music] library failed, retrying with a fresh saved-cookie session');
573
491
  }
574
- };
575
- home = async (_req, res) => {
492
+ const fresh = await refreshAuthedInnertube();
493
+ if (!fresh)
494
+ return { tracks: [], playlists: [] };
495
+ try {
496
+ return await readLibrary(fresh);
497
+ }
498
+ catch (err) {
499
+ logger.warn({ err }, '[music] library retry failed, returning empty');
500
+ return { tracks: [], playlists: [] };
501
+ }
502
+ }),
503
+ home: protectedProcedure.query(async () => {
576
504
  try {
577
505
  const yt = await getInnertube();
578
506
  const feed = await yt.music.getHomeFeed();
579
- res.json(feed);
507
+ return JSON.parse(JSON.stringify(feed));
580
508
  }
581
509
  catch (err) {
582
510
  logger.error({ err }, '[music] home feed failed');
583
- res.json({ contents: [] });
511
+ return { contents: [] };
584
512
  }
585
- };
586
- upnext = async (req, res) => {
587
- const id = String(req.query.id || '').trim();
588
- if (!id)
589
- return res.status(400).json({ error: 'id is required', tracks: [] });
590
- // Signed-in sessions get fresh results with per-track like state; public
591
- // results are cacheable and carry no like state.
513
+ }),
514
+ upnext: protectedProcedure.input(musicTrackInput()).query(async ({ ctx, input }) => {
515
+ const id = requireId(input);
592
516
  const authed = await getAuthedInnertube().catch(() => null);
593
517
  if (!authed) {
594
518
  const cacheKey = `music-upnext-${id.toLowerCase()}`;
595
- const cached = this.cache.get(cacheKey);
519
+ const cached = ctx.apiCache.get(cacheKey);
596
520
  if (cached)
597
- return res.json(cached);
521
+ return cached;
598
522
  try {
599
523
  const yt = await getInnertube();
600
- const panel = await yt.music.getUpNext(id);
601
- const tracks = [];
602
- const contents = panel.contents ?? [];
603
- for (const item of contents) {
604
- const track = upnextToTrack(item);
605
- if (track)
606
- tracks.push(track);
607
- if (tracks.length >= 50)
608
- break;
609
- }
610
- const playlistId = panel.playlist_id ?? null;
611
- const payload = { tracks, playlistId };
612
- this.cache.set(cacheKey, payload, 300);
613
- return res.json(payload);
524
+ const payload = parseUpnext(await yt.music.getUpNext(id), false);
525
+ ctx.apiCache.set(cacheKey, payload, 300);
526
+ return payload;
614
527
  }
615
528
  catch (err) {
616
529
  logger.error({ err }, '[music] upnext failed');
617
- return res.json({ tracks: [], playlistId: null });
530
+ return { tracks: [], playlistId: null };
618
531
  }
619
532
  }
620
533
  try {
621
- const panel = await authed.music.getUpNext(id);
622
- const tracks = [];
623
- const contents = panel.contents ?? [];
624
- for (const item of contents) {
625
- const track = upnextToTrack(item, true);
626
- if (track)
627
- tracks.push(track);
628
- if (tracks.length >= 50)
629
- break;
534
+ return parseUpnext(await authed.music.getUpNext(id), true);
535
+ }
536
+ catch (err) {
537
+ logger.warn({ err }, '[music] authed upnext failed, retrying with a fresh saved-cookie session');
538
+ }
539
+ const fresh = await refreshAuthedInnertube();
540
+ if (fresh) {
541
+ try {
542
+ return parseUpnext(await fresh.music.getUpNext(id), true);
543
+ }
544
+ catch (retryErr) {
545
+ logger.warn({ err: retryErr }, '[music] upnext retry failed, falling back to public');
630
546
  }
631
- const playlistId = panel.playlist_id ?? null;
632
- res.json({ tracks, playlistId });
547
+ }
548
+ try {
549
+ const yt = await getInnertube();
550
+ return parseUpnext(await yt.music.getUpNext(id), false);
633
551
  }
634
552
  catch (err) {
635
553
  logger.error({ err }, '[music] upnext failed');
636
- res.json({ tracks: [], playlistId: null });
554
+ return { tracks: [], playlistId: null };
637
555
  }
638
- };
639
- likedIds = async (_req, res) => {
556
+ }),
557
+ likes: protectedProcedure.query(async ({ ctx }) => {
640
558
  const yt = await getAuthedInnertube().catch(() => null);
641
559
  if (!yt) {
642
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', likedIds: [] });
560
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
643
561
  }
644
562
  const cacheKey = 'music-liked-ids';
645
- const cached = this.cache.get(cacheKey);
563
+ const cached = ctx.apiCache.get(cacheKey);
646
564
  if (cached)
647
- return res.json({ likedIds: cached });
565
+ return { likedIds: cached };
566
+ const fetchLikedIds = async (session) => {
567
+ const tracks = await fetchPlaylistTracks(session, 'LM', 500);
568
+ return tracks.map((t) => t.id);
569
+ };
648
570
  try {
649
- const tracks = await fetchPlaylistTracks(yt, 'LM', 500);
650
- const likedIds = tracks.map((t) => t.id);
651
- this.cache.set(cacheKey, likedIds, 120);
652
- res.json({ likedIds });
571
+ const likedIds = await fetchLikedIds(yt);
572
+ ctx.apiCache.set(cacheKey, likedIds, 120);
573
+ return { likedIds };
653
574
  }
654
575
  catch (err) {
655
- logger.warn({ err }, '[music] liked ids failed, returning empty');
656
- res.json({ likedIds: [] });
576
+ logger.warn({ err }, '[music] liked ids failed, retrying with a fresh saved-cookie session');
577
+ const fresh = await refreshAuthedInnertube();
578
+ if (!fresh)
579
+ return { likedIds: [] };
580
+ try {
581
+ const likedIds = await fetchLikedIds(fresh);
582
+ ctx.apiCache.set(cacheKey, likedIds, 120);
583
+ return { likedIds };
584
+ }
585
+ catch (retryErr) {
586
+ logger.warn({ err: retryErr }, '[music] liked ids retry failed, returning empty');
587
+ return { likedIds: [] };
588
+ }
657
589
  }
658
- };
659
- rate = async (req, res) => {
660
- const id = String(req.body?.id ?? req.query.id ?? '').trim();
661
- const like = req.body?.like === true || req.body?.like === 'true';
590
+ }),
591
+ like: protectedProcedure.input(musicLikeInput()).mutation(async ({ ctx, input }) => {
592
+ const id = String(input.id || '').trim();
593
+ const like = input.like === true || input.like === 'true';
662
594
  if (!id)
663
- return res.status(400).json({ error: 'id is required' });
595
+ throw badRequest('id is required');
664
596
  const yt = await getAuthedInnertube().catch(() => null);
665
597
  if (!yt) {
666
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED' });
598
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
667
599
  }
668
- try {
669
- const actions = yt.actions ??
670
- yt.session?.actions;
600
+ const attempt = async (session) => {
601
+ const actions = session.actions ??
602
+ session.session?.actions;
671
603
  if (!actions)
672
604
  throw new Error('Actions unavailable');
673
- // Only ever call the toggle endpoints YouTube Music itself returns for
674
- // this track (payload status LIKE/INDIFFERENT). No hand-built endpoints:
675
- // raw video ids can differ from the canonical song id and a direct
676
- // /like call would orphan entries in Liked Music.
677
- const panel = await yt.music.getUpNext(id);
605
+ const panel = await session.music.getUpNext(id);
678
606
  const contents = panel.contents ?? [];
679
607
  let endpoint = null;
680
608
  for (const item of contents) {
@@ -686,13 +614,25 @@ export class MusicController {
686
614
  if (!endpoint)
687
615
  throw new Error('Like toggle not found for track');
688
616
  await endpoint.call(actions);
689
- this.cache.delete('music-liked-ids');
690
- logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
691
- res.json({ success: true, liked: like });
617
+ };
618
+ try {
619
+ await attempt(yt);
692
620
  }
693
621
  catch (err) {
694
- logger.error({ err }, '[music] rate failed');
695
- res.status(502).json({ error: 'Like action failed' });
622
+ logger.warn({ err }, '[music] rate failed, retrying with a fresh saved-cookie session');
623
+ const fresh = await refreshAuthedInnertube();
624
+ if (!fresh)
625
+ throw failed('Like action failed');
626
+ try {
627
+ await attempt(fresh);
628
+ }
629
+ catch (retryErr) {
630
+ logger.error({ err: retryErr }, '[music] rate retry failed');
631
+ throw failed('Like action failed');
632
+ }
696
633
  }
697
- };
698
- }
634
+ ctx.apiCache.delete('music-liked-ids');
635
+ logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
636
+ return { success: true, liked: like };
637
+ }),
638
+ });