@serifpersia/dango 3.3.9 → 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-URCiiy5W.js → AnimeCard-CJy1SXH3.js} +1 -1
  3. package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-BoXlVvZ-.js} +1 -1
  4. package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.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-B0qWqhb_.js → LibraryListPage-HzuDavbb.js} +1 -1
  11. package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-DYcQnnsG.js} +1 -1
  12. package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-D8CT9aO0.js} +1 -1
  13. package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-DGK6CYp1.js} +1 -1
  14. package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
  15. package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.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-B7f1-e6i.js → QueueRail-O7f4RqjB.js} +1 -1
  24. package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
  25. package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-n6rr8ygy.js} +1 -1
  26. package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.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-BxfgjP4i.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-BWDwWnHe.js → TvSearch-DuqR-z3v.js} +1 -1
  36. package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-CBvgFrA0.js} +1 -1
  37. package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
  38. package/client/dist/assets/{hls-BO_L1WoN.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-D_o9-QlR.js → useAsmrPopup-e4zNN9ig.js} +1 -1
  43. package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
  44. package/client/dist/assets/{useMangaPopup-TFzkC-tG.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 +0 -2
  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} +155 -288
  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-C28TfSvX.js +0 -2
  126. package/client/dist/assets/AsmrResetProgressModal-6Xh71AgR.js +0 -1
  127. package/client/dist/assets/AvSyncCalibrator-BwCtRW0P.js +0 -10
  128. package/client/dist/assets/Home-BJdbHYja.js +0 -1
  129. package/client/dist/assets/Insights-CjRy42YE.js +0 -1
  130. package/client/dist/assets/MangaReadPage-YCETZRte.js +0 -1
  131. package/client/dist/assets/Mature-BGxRWkV5.js +0 -1
  132. package/client/dist/assets/MediaCard-CAzoxsRS.js +0 -1
  133. package/client/dist/assets/Music-Cgo-yQ7E.js +0 -1
  134. package/client/dist/assets/OptionListMenu--Owe9Jle.js +0 -1
  135. package/client/dist/assets/Player-yRXG2BrP.js +0 -56
  136. package/client/dist/assets/PlayerSettings-DrZLa0CC.js +0 -1
  137. package/client/dist/assets/QueueOptionsButton-BVZyPyKf.js +0 -1
  138. package/client/dist/assets/Radio-BFjxlzuF.js +0 -1
  139. package/client/dist/assets/Search-BOBo0Ogh.js +0 -1
  140. package/client/dist/assets/Settings-DOvNQ0gX.js +0 -21
  141. package/client/dist/assets/Trackers-DZ9KqOWv.js +0 -2
  142. package/client/dist/assets/Tv-5mATEU4p.js +0 -4
  143. package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
  144. package/client/dist/assets/TvInfo-CF-5C5Fs.js +0 -1
  145. package/client/dist/assets/TvPopup-Dfqifj3s.js +0 -1
  146. package/client/dist/assets/Watchlist-B8MHNsAU.js +0 -1
  147. package/client/dist/assets/index-D2RkUJIN.js +0 -178
  148. package/client/dist/assets/useAnimeInfoData-BWShDHqE.js +0 -1
  149. package/client/dist/assets/useAsmr-FyfD9G7b.js +0 -1
  150. package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +0 -1
  151. package/client/dist/assets/useProviders-DDRxa8vl.js +0 -1
  152. package/client/dist/assets/useTv-Bh6WmNnZ.js +0 -1
  153. package/client/dist/assets/useTvLibrary-_Pqm8re1.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, refreshAuthedInnertube, 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')
@@ -170,10 +169,6 @@ function asMenuItems(menu) {
170
169
  const items = menu.items;
171
170
  return Array.isArray(items) ? items : [];
172
171
  }
173
- /** Find the YTM "liked songs" toggle for a track from its up-next menu.
174
- * NOTE: default/toggled swap roles with the current state — when the song is
175
- * already liked, default removes (status INDIFFERENT) and toggled re-adds
176
- * (status LIKE). The payload `status` is the only reliable signal. */
177
172
  function findLikeToggle(item, videoId) {
178
173
  const result = { like: null, unlike: null };
179
174
  const kind = nodeType(item);
@@ -215,6 +210,8 @@ async function fetchPlaylistTracks(yt, id, cap = 500) {
215
210
  const track = toTrack(item);
216
211
  if (track)
217
212
  tracks.push(track);
213
+ if (tracks.length >= cap)
214
+ break;
218
215
  }
219
216
  if (tracks.length >= cap)
220
217
  break;
@@ -288,126 +285,116 @@ async function readLibrary(yt) {
288
285
  logger.info(`[music] library parsed tracks=${tracks.length} playlists=${playlists.length} gridItems=${gridItems.length}`);
289
286
  return { tracks, playlists };
290
287
  }
291
- 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';
292
- const audioCache = new Map();
293
- const AUDIO_CACHE_MS = 5 * 60 * 1000;
294
- async function decipherWith(yt, videoId) {
295
- const info = await yt.music.getInfo(videoId);
296
- const streaming = info.streaming_data;
297
- const formats = streaming?.adaptive_formats ?? [];
298
- const sorted = [...formats]
299
- .filter((f) => f.has_audio && !f.has_video)
300
- .sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
301
- const player = yt.session.player;
302
- for (const fmt of sorted) {
303
- try {
304
- const url = await fmt.decipher(player);
305
- if (url && url.startsWith('http')) {
306
- const entry = { url, mimeType: fmt.mime_type, fetchedAt: Date.now() };
307
- audioCache.set(videoId, entry);
308
- return entry;
309
- }
310
- }
311
- catch {
312
- // ignore
313
- }
314
- }
315
- throw new Error('No stream available');
288
+ function badRequest(message) {
289
+ return new TRPCError({ code: 'BAD_REQUEST', message });
316
290
  }
317
- async function decipherAudio(videoId) {
318
- const cached = audioCache.get(videoId);
319
- if (cached && Date.now() - cached.fetchedAt < AUDIO_CACHE_MS)
320
- return cached;
321
- const authed = await getAuthedInnertube().catch(() => null);
322
- if (authed) {
323
- try {
324
- return await decipherWith(authed, videoId);
325
- }
326
- catch (err) {
327
- logger.warn({ err }, '[music] authed stream lookup failed, retrying with a fresh saved-cookie session');
328
- const fresh = await refreshAuthedInnertube();
329
- if (fresh) {
330
- try {
331
- return await decipherWith(fresh, videoId);
332
- }
333
- catch (retryErr) {
334
- logger.warn({ err: retryErr }, '[music] refreshed stream lookup failed, falling back to public session');
335
- }
336
- }
337
- }
338
- }
339
- return decipherWith(await getInnertube(), videoId);
291
+ function unauthorized(message) {
292
+ return new TRPCError({ code: 'UNAUTHORIZED', message });
340
293
  }
341
- const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
342
- export class MusicController {
343
- cache;
344
- constructor(cache) {
345
- this.cache = cache;
346
- }
347
- getAuthStatus = (_req, res) => {
348
- res.json(getMusicAuthStatus());
349
- };
350
- startAuth = async (req, res) => {
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
+ }
351
355
  try {
352
- let cookie = String(req.body?.cookie || '').trim();
353
- if (!cookie)
354
- return res.status(400).json({ error: 'Cookie is required' });
355
- cookie = cookie
356
- .replace(/^cookie\s*:\s*/i, '')
357
- .replace(/^["']+|["']+$/g, '')
358
- .trim();
359
- if (cookie.includes('…') || cookie.includes('...')) {
360
- return res.status(400).json({
361
- error: `Cookie looks truncated (${cookie.length} chars). In DevTools, right-click the Cookie header → Copy value (do not copy from the wrapped preview).`,
362
- });
363
- }
364
- if (!cookie.includes('SID') && !cookie.includes('SAPISID')) {
365
- return res.status(400).json({
366
- 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.',
367
- });
368
- }
369
356
  await saveMusicCookie(cookie);
370
357
  const yt = await getAuthedInnertube();
371
358
  if (yt) {
372
359
  try {
373
360
  const lib = await readLibrary(yt);
374
- return res.json({
361
+ return {
375
362
  success: true,
376
363
  tracks: lib.tracks.length,
377
364
  playlists: lib.playlists.length,
378
- });
365
+ };
379
366
  }
380
367
  catch {
381
368
  // ignore
382
369
  }
383
370
  }
384
- res.json({ success: true, tracks: 0, playlists: 0 });
371
+ return { success: true, tracks: 0, playlists: 0 };
385
372
  }
386
373
  catch (err) {
374
+ if (err instanceof TRPCError)
375
+ throw err;
387
376
  logger.error({ err }, '[music] cookie sign-in failed');
388
- res.status(401).json({
389
- error: 'Cookie rejected by YouTube Music. Copy a fresh Cookie header from music.youtube.com.',
390
- });
377
+ throw unauthorized('Cookie rejected by YouTube Music. Copy a fresh Cookie header from music.youtube.com.');
391
378
  }
392
- };
393
- signOut = async (_req, res) => {
379
+ }),
380
+ signOut: protectedProcedure.mutation(async () => {
394
381
  try {
395
382
  await signOutMusic();
396
- res.json({ success: true });
383
+ return { success: true };
397
384
  }
398
385
  catch (err) {
399
386
  logger.error({ err }, '[music] sign-out failed');
400
- res.status(500).json({ error: 'Sign-out failed' });
387
+ throw failed('Sign-out failed');
401
388
  }
402
- };
403
- search = async (req, res) => {
404
- const q = String(req.query.q || '').trim();
389
+ }),
390
+ search: protectedProcedure.input(musicSearchInput()).query(async ({ ctx, input }) => {
391
+ const q = String(input.q || '').trim();
405
392
  if (!q)
406
- return res.json({ tracks: [] });
393
+ return { tracks: [] };
407
394
  const cacheKey = `music-search-${q.toLowerCase()}`;
408
- const cached = this.cache.get(cacheKey);
395
+ const cached = ctx.apiCache.get(cacheKey);
409
396
  if (cached)
410
- return res.json({ tracks: cached });
397
+ return { tracks: cached };
411
398
  try {
412
399
  const yt = await getInnertube();
413
400
  const result = await yt.music.search(q, { type: 'song' });
@@ -425,136 +412,26 @@ export class MusicController {
425
412
  if (tracks.length >= 25)
426
413
  break;
427
414
  }
428
- this.cache.set(cacheKey, tracks, 300);
429
- res.json({ tracks });
415
+ ctx.apiCache.set(cacheKey, tracks, 300);
416
+ return { tracks };
430
417
  }
431
418
  catch (err) {
432
419
  logger.error({ err }, '[music] search failed');
433
- res.json({ tracks: [] });
420
+ return { tracks: [] };
434
421
  }
435
- };
436
- stream = async (req, res) => {
437
- const videoId = String(req.query.videoId || req.query.id || '').trim();
438
- if (!videoId)
439
- return res.status(400).json({ error: 'videoId is required' });
440
- try {
441
- const entry = await decipherAudio(videoId);
442
- res.json({ url: entry.url, mimeType: entry.mimeType });
443
- }
444
- catch (err) {
445
- logger.error({ err }, '[music] stream failed');
446
- res.status(502).json({ error: 'Stream lookup failed' });
447
- }
448
- };
449
- audio = async (req, res) => {
450
- const videoId = String(req.query.videoId || req.query.id || '').trim();
451
- if (!videoId)
452
- return res.status(400).json({ error: 'videoId is required' });
453
- const aborter = new AbortController();
454
- res.on('close', () => {
455
- if (!res.writableEnded)
456
- aborter.abort();
457
- });
458
- try {
459
- let entry;
460
- try {
461
- entry = await decipherAudio(videoId);
462
- }
463
- catch {
464
- return res.status(502).json({ error: 'No stream available' });
465
- }
466
- const timeout = AbortSignal.timeout(30000);
467
- const signal = aborter.signal.aborted
468
- ? aborter.signal
469
- : AbortSignal.any([aborter.signal, timeout]);
470
- let upstream;
471
- try {
472
- upstream = await fetch(entry.url, {
473
- headers: {
474
- 'User-Agent': UPSTREAM_UA,
475
- Referer: 'https://music.youtube.com/',
476
- Origin: 'https://music.youtube.com/',
477
- Range: typeof req.headers.range === 'string' ? req.headers.range : 'bytes=0-',
478
- },
479
- signal,
480
- });
481
- }
482
- catch (err) {
483
- if (aborter.signal.aborted)
484
- return;
485
- logger.error({ err }, '[music] audio upstream failed');
486
- if (!res.headersSent)
487
- res.status(502).json({ error: 'Upstream error' });
488
- return;
489
- }
490
- if (upstream.status !== 200 && upstream.status !== 206) {
491
- try {
492
- await upstream.body?.cancel();
493
- }
494
- catch {
495
- // ignore
496
- }
497
- audioCache.delete(videoId);
498
- if (!res.headersSent)
499
- res.status(502).json({ error: 'Upstream error' });
500
- return;
501
- }
502
- res.status(upstream.status);
503
- for (const name of PASSTHROUGH_HEADERS) {
504
- const value = upstream.headers.get(name);
505
- if (value)
506
- res.set(name === 'content-type' ? 'Content-Type' : name, value);
507
- }
508
- if (!res.get('Content-Type'))
509
- res.set('Content-Type', entry.mimeType || 'audio/webm');
510
- res.set('Accept-Ranges', 'bytes');
511
- res.set('Access-Control-Allow-Origin', '*');
512
- if (!upstream.body) {
513
- if (!res.headersSent)
514
- res.status(502).json({ error: 'Upstream error' });
515
- return;
516
- }
517
- try {
518
- const nodeStream = Readable.fromWeb(upstream.body);
519
- nodeStream.on('error', () => {
520
- try {
521
- if (!res.writableEnded)
522
- res.destroy();
523
- }
524
- catch {
525
- // ignore
526
- }
527
- });
528
- nodeStream.pipe(res);
529
- }
530
- catch (err) {
531
- logger.error({ err }, '[music] audio pipe failed');
532
- if (!res.headersSent)
533
- res.status(502).json({ error: 'Upstream error' });
534
- }
535
- }
536
- catch (err) {
537
- if (aborter.signal.aborted)
538
- return;
539
- logger.error({ err }, '[music] audio failed');
540
- if (!res.headersSent)
541
- res.status(502).json({ error: 'Stream lookup failed' });
542
- }
543
- };
544
- track = async (req, res) => {
545
- const id = String(req.query.id || '').trim();
546
- if (!id)
547
- 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);
548
425
  const cacheKey = `music-track-${id.toLowerCase()}`;
549
- const cached = this.cache.get(cacheKey);
426
+ const cached = ctx.apiCache.get(cacheKey);
550
427
  if (cached)
551
- return res.json({ track: cached });
428
+ return { track: cached };
552
429
  try {
553
430
  const yt = await getInnertube();
554
431
  const info = await yt.music.getInfo(id);
555
432
  const basic = info.basic_info;
556
433
  if (!basic?.title)
557
- return res.json({ track: null });
434
+ return { track: null };
558
435
  const durationSec = typeof basic.duration === 'number' && Number.isFinite(basic.duration)
559
436
  ? Math.max(Math.round(basic.duration), 0)
560
437
  : 0;
@@ -567,48 +444,46 @@ export class MusicController {
567
444
  : undefined,
568
445
  thumbnails: Array.isArray(basic.thumbnail) ? basic.thumbnail : [],
569
446
  };
570
- this.cache.set(cacheKey, track, 300);
571
- res.json({ track });
447
+ ctx.apiCache.set(cacheKey, track, 300);
448
+ return { track: track };
572
449
  }
573
450
  catch (err) {
574
451
  logger.error({ err }, '[music] track lookup failed');
575
- res.json({ track: null });
452
+ return { track: null };
576
453
  }
577
- };
578
- playlist = async (req, res) => {
579
- const id = String(req.query.id || '').trim();
580
- if (!id)
581
- return res.status(400).json({ error: 'id is required', tracks: [] });
454
+ }),
455
+ playlist: protectedProcedure.input(musicTrackInput()).query(async ({ input }) => {
456
+ const id = requireId(input);
582
457
  const yt = await getAuthedInnertube().catch(() => null);
583
458
  if (!yt) {
584
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [] });
459
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
585
460
  }
586
461
  try {
587
- res.json({ tracks: await fetchPlaylistTracks(yt, id) });
462
+ return { tracks: await fetchPlaylistTracks(yt, id) };
588
463
  }
589
464
  catch (err) {
590
465
  logger.warn({ err }, '[music] playlist failed, retrying with a fresh saved-cookie session');
591
466
  const fresh = await refreshAuthedInnertube();
592
467
  if (!fresh)
593
- return res.json({ tracks: [] });
468
+ return { tracks: [] };
594
469
  try {
595
- res.json({ tracks: await fetchPlaylistTracks(fresh, id) });
470
+ return { tracks: await fetchPlaylistTracks(fresh, id) };
596
471
  }
597
472
  catch (retryErr) {
598
473
  logger.warn({ err: retryErr }, '[music] playlist retry failed, returning empty');
599
- res.json({ tracks: [] });
474
+ return { tracks: [] };
600
475
  }
601
476
  }
602
- };
603
- library = async (_req, res) => {
477
+ }),
478
+ library: protectedProcedure.query(async () => {
604
479
  const yt = await getAuthedInnertube().catch(() => null);
605
480
  if (!yt) {
606
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [], playlists: [] });
481
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
607
482
  }
608
483
  try {
609
484
  const first = await readLibrary(yt);
610
485
  if (first.tracks.length > 0 || first.playlists.length > 0)
611
- return res.json(first);
486
+ return first;
612
487
  logger.warn('[music] library came back empty, retrying with a fresh saved-cookie session');
613
488
  }
614
489
  catch (err) {
@@ -616,51 +491,47 @@ export class MusicController {
616
491
  }
617
492
  const fresh = await refreshAuthedInnertube();
618
493
  if (!fresh)
619
- return res.json({ tracks: [], playlists: [] });
494
+ return { tracks: [], playlists: [] };
620
495
  try {
621
- res.json(await readLibrary(fresh));
496
+ return await readLibrary(fresh);
622
497
  }
623
498
  catch (err) {
624
499
  logger.warn({ err }, '[music] library retry failed, returning empty');
625
- res.json({ tracks: [], playlists: [] });
500
+ return { tracks: [], playlists: [] };
626
501
  }
627
- };
628
- home = async (_req, res) => {
502
+ }),
503
+ home: protectedProcedure.query(async () => {
629
504
  try {
630
505
  const yt = await getInnertube();
631
506
  const feed = await yt.music.getHomeFeed();
632
- res.json(feed);
507
+ return JSON.parse(JSON.stringify(feed));
633
508
  }
634
509
  catch (err) {
635
510
  logger.error({ err }, '[music] home feed failed');
636
- res.json({ contents: [] });
511
+ return { contents: [] };
637
512
  }
638
- };
639
- upnext = async (req, res) => {
640
- const id = String(req.query.id || '').trim();
641
- if (!id)
642
- return res.status(400).json({ error: 'id is required', tracks: [] });
643
- // Signed-in sessions get fresh results with per-track like state; public
644
- // results are cacheable and carry no like state.
513
+ }),
514
+ upnext: protectedProcedure.input(musicTrackInput()).query(async ({ ctx, input }) => {
515
+ const id = requireId(input);
645
516
  const authed = await getAuthedInnertube().catch(() => null);
646
517
  if (!authed) {
647
518
  const cacheKey = `music-upnext-${id.toLowerCase()}`;
648
- const cached = this.cache.get(cacheKey);
519
+ const cached = ctx.apiCache.get(cacheKey);
649
520
  if (cached)
650
- return res.json(cached);
521
+ return cached;
651
522
  try {
652
523
  const yt = await getInnertube();
653
524
  const payload = parseUpnext(await yt.music.getUpNext(id), false);
654
- this.cache.set(cacheKey, payload, 300);
655
- return res.json(payload);
525
+ ctx.apiCache.set(cacheKey, payload, 300);
526
+ return payload;
656
527
  }
657
528
  catch (err) {
658
529
  logger.error({ err }, '[music] upnext failed');
659
- return res.json({ tracks: [], playlistId: null });
530
+ return { tracks: [], playlistId: null };
660
531
  }
661
532
  }
662
533
  try {
663
- return res.json(parseUpnext(await authed.music.getUpNext(id), true));
534
+ return parseUpnext(await authed.music.getUpNext(id), true);
664
535
  }
665
536
  catch (err) {
666
537
  logger.warn({ err }, '[music] authed upnext failed, retrying with a fresh saved-cookie session');
@@ -668,7 +539,7 @@ export class MusicController {
668
539
  const fresh = await refreshAuthedInnertube();
669
540
  if (fresh) {
670
541
  try {
671
- return res.json(parseUpnext(await fresh.music.getUpNext(id), true));
542
+ return parseUpnext(await fresh.music.getUpNext(id), true);
672
543
  }
673
544
  catch (retryErr) {
674
545
  logger.warn({ err: retryErr }, '[music] upnext retry failed, falling back to public');
@@ -676,65 +547,61 @@ export class MusicController {
676
547
  }
677
548
  try {
678
549
  const yt = await getInnertube();
679
- return res.json(parseUpnext(await yt.music.getUpNext(id), false));
550
+ return parseUpnext(await yt.music.getUpNext(id), false);
680
551
  }
681
552
  catch (err) {
682
553
  logger.error({ err }, '[music] upnext failed');
683
- return res.json({ tracks: [], playlistId: null });
554
+ return { tracks: [], playlistId: null };
684
555
  }
685
- };
686
- likedIds = async (_req, res) => {
556
+ }),
557
+ likes: protectedProcedure.query(async ({ ctx }) => {
687
558
  const yt = await getAuthedInnertube().catch(() => null);
688
559
  if (!yt) {
689
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', likedIds: [] });
560
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
690
561
  }
691
562
  const cacheKey = 'music-liked-ids';
692
- const cached = this.cache.get(cacheKey);
563
+ const cached = ctx.apiCache.get(cacheKey);
693
564
  if (cached)
694
- return res.json({ likedIds: cached });
565
+ return { likedIds: cached };
695
566
  const fetchLikedIds = async (session) => {
696
567
  const tracks = await fetchPlaylistTracks(session, 'LM', 500);
697
568
  return tracks.map((t) => t.id);
698
569
  };
699
570
  try {
700
571
  const likedIds = await fetchLikedIds(yt);
701
- this.cache.set(cacheKey, likedIds, 120);
702
- res.json({ likedIds });
572
+ ctx.apiCache.set(cacheKey, likedIds, 120);
573
+ return { likedIds };
703
574
  }
704
575
  catch (err) {
705
576
  logger.warn({ err }, '[music] liked ids failed, retrying with a fresh saved-cookie session');
706
577
  const fresh = await refreshAuthedInnertube();
707
578
  if (!fresh)
708
- return res.json({ likedIds: [] });
579
+ return { likedIds: [] };
709
580
  try {
710
581
  const likedIds = await fetchLikedIds(fresh);
711
- this.cache.set(cacheKey, likedIds, 120);
712
- res.json({ likedIds });
582
+ ctx.apiCache.set(cacheKey, likedIds, 120);
583
+ return { likedIds };
713
584
  }
714
585
  catch (retryErr) {
715
586
  logger.warn({ err: retryErr }, '[music] liked ids retry failed, returning empty');
716
- res.json({ likedIds: [] });
587
+ return { likedIds: [] };
717
588
  }
718
589
  }
719
- };
720
- rate = async (req, res) => {
721
- const id = String(req.body?.id ?? req.query.id ?? '').trim();
722
- 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';
723
594
  if (!id)
724
- return res.status(400).json({ error: 'id is required' });
595
+ throw badRequest('id is required');
725
596
  const yt = await getAuthedInnertube().catch(() => null);
726
597
  if (!yt) {
727
- return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED' });
598
+ throw unauthorized('MUSIC_AUTH_REQUIRED');
728
599
  }
729
600
  const attempt = async (session) => {
730
601
  const actions = session.actions ??
731
602
  session.session?.actions;
732
603
  if (!actions)
733
604
  throw new Error('Actions unavailable');
734
- // Only ever call the toggle endpoints YouTube Music itself returns for
735
- // this track (payload status LIKE/INDIFFERENT). No hand-built endpoints:
736
- // raw video ids can differ from the canonical song id and a direct
737
- // /like call would orphan entries in Liked Music.
738
605
  const panel = await session.music.getUpNext(id);
739
606
  const contents = panel.contents ?? [];
740
607
  let endpoint = null;
@@ -755,17 +622,17 @@ export class MusicController {
755
622
  logger.warn({ err }, '[music] rate failed, retrying with a fresh saved-cookie session');
756
623
  const fresh = await refreshAuthedInnertube();
757
624
  if (!fresh)
758
- return res.status(502).json({ error: 'Like action failed' });
625
+ throw failed('Like action failed');
759
626
  try {
760
627
  await attempt(fresh);
761
628
  }
762
629
  catch (retryErr) {
763
630
  logger.error({ err: retryErr }, '[music] rate retry failed');
764
- return res.status(502).json({ error: 'Like action failed' });
631
+ throw failed('Like action failed');
765
632
  }
766
633
  }
767
- this.cache.delete('music-liked-ids');
634
+ ctx.apiCache.delete('music-liked-ids');
768
635
  logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
769
- res.json({ success: true, liked: like });
770
- };
771
- }
636
+ return { success: true, liked: like };
637
+ }),
638
+ });