@serifpersia/dango 3.3.9 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/client/dist/assets/{AnimeCard-URCiiy5W.js → AnimeCard-B0oMgXBm.js} +1 -1
- package/client/dist/assets/{AnimeInfo-e3z3er9t.js → AnimeInfo-o0Udf0og.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-B7Djlmy_.js → AnimeInfoPage-CojMIEuf.js} +2 -2
- package/client/dist/assets/Asmr-2V3yCCiK.js +2 -0
- package/client/dist/assets/AsmrResetProgressModal-C9R7HlNa.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-Bo3820j3.js +10 -0
- package/client/dist/assets/Home-C8uXTzIM.js +1 -0
- package/client/dist/assets/Insights-CRqBfZuK.js +1 -0
- package/client/dist/assets/{LibraryListPage-B0qWqhb_.js → LibraryListPage-B_r51cRi.js} +1 -1
- package/client/dist/assets/{ListeningList-CSqD8j1C.js → ListeningList-BWnwvJTt.js} +1 -1
- package/client/dist/assets/{Manga-Cg2YuObF.js → Manga-CI5cZg3h.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-DcArhEsy.js → MangaInfoPage-BtkOrO34.js} +1 -1
- package/client/dist/assets/MangaReadPage-Uv72AYdY.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-D_yT3d1v.js → MangaResetProgressModal-CPMEqZyi.js} +1 -1
- package/client/dist/assets/Mature-DXHWvoY5.js +1 -0
- package/client/dist/assets/MediaCard-DzA336CA.js +1 -0
- package/client/dist/assets/Music-C4dM0BQx.js +1 -0
- package/client/dist/assets/OptionListMenu-CpwxAC3J.js +1 -0
- package/client/dist/assets/Player-CpMvWTkH.js +56 -0
- package/client/dist/assets/PlayerSettings-pMU85k7p.js +1 -0
- package/client/dist/assets/QueueOptionsButton-1M7MdKzM.js +1 -0
- package/client/dist/assets/{QueueRail-B7f1-e6i.js → QueueRail-Bpla2xD8.js} +1 -1
- package/client/dist/assets/Radio-DlmTC-qG.js +1 -0
- package/client/dist/assets/{ReadingList-BaIem8H1.js → ReadingList-wJMDUybm.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-DES5Y-Fl.js → ResetProgressModal-CByRM1Fh.js} +1 -1
- package/client/dist/assets/Search-B94D0tRM.js +1 -0
- package/client/dist/assets/Settings-CcU3oNY8.js +21 -0
- package/client/dist/assets/{SkeletonGrid-BxfgjP4i.js → SkeletonGrid-BJyZkg_K.js} +1 -1
- package/client/dist/assets/Trackers-vz-gqxtm.js +2 -0
- package/client/dist/assets/Tv-Rji_lvQo.js +4 -0
- package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
- package/client/dist/assets/TvInfo-Ciujd6os.js +1 -0
- package/client/dist/assets/TvPopup-CqlVRF-Y.js +1 -0
- package/client/dist/assets/{TvSearch-BWDwWnHe.js → TvSearch-BcKXQue9.js} +1 -1
- package/client/dist/assets/{TvWatchlist-D3n5_4MI.js → TvWatchlist-sjsiup2h.js} +1 -1
- package/client/dist/assets/Watchlist-DAMdvt7Z.js +1 -0
- package/client/dist/assets/{hls-BO_L1WoN.js → hls-Do_n10_Y.js} +1 -1
- package/client/dist/assets/index-B5SLDCAi.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DU9X90no.js +1 -0
- package/client/dist/assets/useAsmr-D6cSjbvt.js +1 -0
- package/client/dist/assets/{useAsmrPopup-D_o9-QlR.js → useAsmrPopup-DK1Vx-HV.js} +1 -1
- package/client/dist/assets/useMangaLibrary-DcxPn0RM.js +1 -0
- package/client/dist/assets/{useMangaPopup-TFzkC-tG.js → useMangaPopup-BLwvRZn0.js} +1 -1
- package/client/dist/assets/useProviders-D3L1Chqc.js +1 -0
- package/client/dist/assets/useTv-B546u-Rx.js +1 -0
- package/client/dist/assets/useTvLibrary-C2rPeCVT.js +1 -0
- package/client/dist/assets/{vendor-query-BXRECSI6.js → vendor-query-BUTye89G.js} +1 -1
- package/client/dist/index.html +2 -2
- package/orchestrator.js +1 -2
- package/package.json +6 -6
- package/server/dist/app-auth.js +9 -29
- package/server/dist/app-hono.js +621 -0
- package/server/dist/config.js +0 -1
- package/server/dist/db/drizzle.js +32 -0
- package/server/dist/db/schema-anime.js +64 -0
- package/server/dist/db/schema-asmr.js +25 -0
- package/server/dist/db/schema-manga.js +31 -0
- package/server/dist/db/schema-tv.js +33 -0
- package/server/dist/db.js +75 -4
- package/server/dist/discord-gateway.js +1 -1
- package/server/dist/google.js +0 -7
- package/server/dist/hono/asmr.js +1 -0
- package/server/dist/hono/auth.js +56 -0
- package/server/dist/hono/music.js +419 -0
- package/server/dist/{controllers/proxy.controller.js → hono/proxy.js} +390 -394
- package/server/dist/hono/tv.js +197 -0
- package/server/dist/hono/watchlist.js +733 -0
- package/server/dist/lib/anilist.js +3 -3
- package/server/dist/lib/insights.js +185 -0
- package/server/dist/lib/ipc.js +1 -2
- package/server/dist/lib/kitsu.js +1 -28
- package/server/dist/lib/mal-import.js +291 -0
- package/server/dist/lib/migration.js +20 -34
- package/server/dist/lib/offline-db.js +36 -36
- package/server/dist/lib/tracker/manga-sync.service.js +21 -21
- package/server/dist/lib/tracker/sync.service.js +20 -20
- package/server/dist/lib/translate.js +79 -0
- package/server/dist/lib/ytmusic.js +0 -2
- package/server/dist/providers/title-matching.js +0 -1
- package/server/dist/repositories/asmr.repository.js +90 -87
- package/server/dist/repositories/insights.repository.js +24 -13
- package/server/dist/repositories/library.repository.js +33 -93
- package/server/dist/repositories/manga.repository.js +108 -116
- package/server/dist/repositories/notifications.repository.js +50 -31
- package/server/dist/repositories/queue.repository.js +36 -25
- package/server/dist/repositories/settings.repository.js +18 -8
- package/server/dist/repositories/shows-meta.repository.js +54 -41
- package/server/dist/repositories/temp-show-ids.repository.js +43 -31
- package/server/dist/repositories/tv.repository.js +98 -115
- package/server/dist/repositories/watched-episodes.repository.js +52 -20
- package/server/dist/repositories/watchlist.repository.js +36 -48
- package/server/dist/server.js +59 -151
- package/server/dist/sync.js +41 -4
- package/server/dist/trpc/context.js +7 -0
- package/server/dist/trpc/errors.js +21 -0
- package/server/dist/trpc/index.js +19 -0
- package/server/dist/trpc/router.js +49 -0
- package/server/dist/trpc/routers/asmr-library.js +122 -0
- package/server/dist/trpc/routers/asmr-progress.js +133 -0
- package/server/dist/trpc/routers/asmr.js +87 -0
- package/server/dist/trpc/routers/auth.js +247 -0
- package/server/dist/trpc/routers/continue-watching.js +83 -0
- package/server/dist/trpc/routers/data.js +1039 -0
- package/server/dist/trpc/routers/discord.js +288 -0
- package/server/dist/trpc/routers/discovery.js +15 -0
- package/server/dist/trpc/routers/insights.js +45 -0
- package/server/dist/trpc/routers/manga-library.js +280 -0
- package/server/dist/trpc/routers/manga-progress.js +181 -0
- package/server/dist/trpc/routers/manga.js +201 -0
- package/server/dist/{controllers/music.controller.js → trpc/routers/music.js} +155 -288
- package/server/dist/trpc/routers/notifications.js +80 -0
- package/server/dist/trpc/routers/progress.js +15 -0
- package/server/dist/trpc/routers/queue.js +95 -0
- package/server/dist/{routes/radio.routes.js → trpc/routers/radio.js} +30 -23
- package/server/dist/trpc/routers/settings.js +105 -0
- package/server/dist/trpc/routers/tracker.js +219 -0
- package/server/dist/trpc/routers/tv-library.js +145 -0
- package/server/dist/trpc/routers/tv-progress.js +171 -0
- package/server/dist/trpc/routers/tv.js +565 -0
- package/server/dist/trpc/routers/watchlist.js +564 -0
- package/server/dist/trpc/validation.js +159 -0
- package/server/dist/utils/security.utils.js +0 -18
- package/server/package.json +5 -10
- package/client/dist/assets/Asmr-C28TfSvX.js +0 -2
- package/client/dist/assets/AsmrResetProgressModal-6Xh71AgR.js +0 -1
- package/client/dist/assets/AvSyncCalibrator-BwCtRW0P.js +0 -10
- package/client/dist/assets/Home-BJdbHYja.js +0 -1
- package/client/dist/assets/Insights-CjRy42YE.js +0 -1
- package/client/dist/assets/MangaReadPage-YCETZRte.js +0 -1
- package/client/dist/assets/Mature-BGxRWkV5.js +0 -1
- package/client/dist/assets/MediaCard-CAzoxsRS.js +0 -1
- package/client/dist/assets/Music-Cgo-yQ7E.js +0 -1
- package/client/dist/assets/OptionListMenu--Owe9Jle.js +0 -1
- package/client/dist/assets/Player-yRXG2BrP.js +0 -56
- package/client/dist/assets/PlayerSettings-DrZLa0CC.js +0 -1
- package/client/dist/assets/QueueOptionsButton-BVZyPyKf.js +0 -1
- package/client/dist/assets/Radio-BFjxlzuF.js +0 -1
- package/client/dist/assets/Search-BOBo0Ogh.js +0 -1
- package/client/dist/assets/Settings-DOvNQ0gX.js +0 -21
- package/client/dist/assets/Trackers-DZ9KqOWv.js +0 -2
- package/client/dist/assets/Tv-5mATEU4p.js +0 -4
- package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
- package/client/dist/assets/TvInfo-CF-5C5Fs.js +0 -1
- package/client/dist/assets/TvPopup-Dfqifj3s.js +0 -1
- package/client/dist/assets/Watchlist-B8MHNsAU.js +0 -1
- package/client/dist/assets/index-D2RkUJIN.js +0 -178
- package/client/dist/assets/useAnimeInfoData-BWShDHqE.js +0 -1
- package/client/dist/assets/useAsmr-FyfD9G7b.js +0 -1
- package/client/dist/assets/useMangaLibrary-BJU5x8Vz.js +0 -1
- package/client/dist/assets/useProviders-DDRxa8vl.js +0 -1
- package/client/dist/assets/useTv-Bh6WmNnZ.js +0 -1
- package/client/dist/assets/useTvLibrary-_Pqm8re1.js +0 -1
- package/server/dist/controllers/asmr-library.controller.js +0 -288
- package/server/dist/controllers/auth.controller.js +0 -225
- package/server/dist/controllers/data.controller.js +0 -767
- package/server/dist/controllers/insights.controller.js +0 -203
- package/server/dist/controllers/manga-library.controller.js +0 -350
- package/server/dist/controllers/settings.controller.js +0 -609
- package/server/dist/controllers/tv-library.controller.js +0 -314
- package/server/dist/controllers/watchlist.controller.js +0 -1069
- package/server/dist/request-logger.js +0 -16
- package/server/dist/routes/asmr-library.routes.js +0 -23
- package/server/dist/routes/asmr.routes.js +0 -81
- package/server/dist/routes/auth.routes.js +0 -25
- package/server/dist/routes/data.routes.js +0 -47
- package/server/dist/routes/discord-gateway.routes.js +0 -51
- package/server/dist/routes/insights.routes.js +0 -35
- package/server/dist/routes/lan-auth.routes.js +0 -46
- package/server/dist/routes/manga-library.routes.js +0 -25
- package/server/dist/routes/manga.routes.js +0 -141
- package/server/dist/routes/music.routes.js +0 -20
- package/server/dist/routes/providers.routes.js +0 -17
- package/server/dist/routes/proxy.routes.js +0 -12
- package/server/dist/routes/settings.routes.js +0 -24
- package/server/dist/routes/tracker.routes.js +0 -287
- package/server/dist/routes/translate.routes.js +0 -88
- package/server/dist/routes/tv-library.routes.js +0 -23
- package/server/dist/routes/tv.routes.js +0 -629
- package/server/dist/routes/watchlist.routes.js +0 -341
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
379
|
+
}),
|
|
380
|
+
signOut: protectedProcedure.mutation(async () => {
|
|
394
381
|
try {
|
|
395
382
|
await signOutMusic();
|
|
396
|
-
|
|
383
|
+
return { success: true };
|
|
397
384
|
}
|
|
398
385
|
catch (err) {
|
|
399
386
|
logger.error({ err }, '[music] sign-out failed');
|
|
400
|
-
|
|
387
|
+
throw failed('Sign-out failed');
|
|
401
388
|
}
|
|
402
|
-
}
|
|
403
|
-
search
|
|
404
|
-
const q = String(
|
|
389
|
+
}),
|
|
390
|
+
search: protectedProcedure.input(musicSearchInput()).query(async ({ ctx, input }) => {
|
|
391
|
+
const q = String(input.q || '').trim();
|
|
405
392
|
if (!q)
|
|
406
|
-
return
|
|
393
|
+
return { tracks: [] };
|
|
407
394
|
const cacheKey = `music-search-${q.toLowerCase()}`;
|
|
408
|
-
const cached =
|
|
395
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
409
396
|
if (cached)
|
|
410
|
-
return
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
420
|
+
return { tracks: [] };
|
|
434
421
|
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const
|
|
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 =
|
|
426
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
550
427
|
if (cached)
|
|
551
|
-
return
|
|
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
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
452
|
+
return { track: null };
|
|
576
453
|
}
|
|
577
|
-
}
|
|
578
|
-
playlist
|
|
579
|
-
const id =
|
|
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
|
-
|
|
459
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
585
460
|
}
|
|
586
461
|
try {
|
|
587
|
-
|
|
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
|
|
468
|
+
return { tracks: [] };
|
|
594
469
|
try {
|
|
595
|
-
|
|
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
|
-
|
|
474
|
+
return { tracks: [] };
|
|
600
475
|
}
|
|
601
476
|
}
|
|
602
|
-
}
|
|
603
|
-
library
|
|
477
|
+
}),
|
|
478
|
+
library: protectedProcedure.query(async () => {
|
|
604
479
|
const yt = await getAuthedInnertube().catch(() => null);
|
|
605
480
|
if (!yt) {
|
|
606
|
-
|
|
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
|
|
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
|
|
494
|
+
return { tracks: [], playlists: [] };
|
|
620
495
|
try {
|
|
621
|
-
|
|
496
|
+
return await readLibrary(fresh);
|
|
622
497
|
}
|
|
623
498
|
catch (err) {
|
|
624
499
|
logger.warn({ err }, '[music] library retry failed, returning empty');
|
|
625
|
-
|
|
500
|
+
return { tracks: [], playlists: [] };
|
|
626
501
|
}
|
|
627
|
-
}
|
|
628
|
-
home
|
|
502
|
+
}),
|
|
503
|
+
home: protectedProcedure.query(async () => {
|
|
629
504
|
try {
|
|
630
505
|
const yt = await getInnertube();
|
|
631
506
|
const feed = await yt.music.getHomeFeed();
|
|
632
|
-
|
|
507
|
+
return JSON.parse(JSON.stringify(feed));
|
|
633
508
|
}
|
|
634
509
|
catch (err) {
|
|
635
510
|
logger.error({ err }, '[music] home feed failed');
|
|
636
|
-
|
|
511
|
+
return { contents: [] };
|
|
637
512
|
}
|
|
638
|
-
}
|
|
639
|
-
upnext
|
|
640
|
-
const id =
|
|
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 =
|
|
519
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
649
520
|
if (cached)
|
|
650
|
-
return
|
|
521
|
+
return cached;
|
|
651
522
|
try {
|
|
652
523
|
const yt = await getInnertube();
|
|
653
524
|
const payload = parseUpnext(await yt.music.getUpNext(id), false);
|
|
654
|
-
|
|
655
|
-
return
|
|
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
|
|
530
|
+
return { tracks: [], playlistId: null };
|
|
660
531
|
}
|
|
661
532
|
}
|
|
662
533
|
try {
|
|
663
|
-
return
|
|
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
|
|
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
|
|
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
|
|
554
|
+
return { tracks: [], playlistId: null };
|
|
684
555
|
}
|
|
685
|
-
}
|
|
686
|
-
|
|
556
|
+
}),
|
|
557
|
+
likes: protectedProcedure.query(async ({ ctx }) => {
|
|
687
558
|
const yt = await getAuthedInnertube().catch(() => null);
|
|
688
559
|
if (!yt) {
|
|
689
|
-
|
|
560
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
690
561
|
}
|
|
691
562
|
const cacheKey = 'music-liked-ids';
|
|
692
|
-
const cached =
|
|
563
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
693
564
|
if (cached)
|
|
694
|
-
return
|
|
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
|
-
|
|
702
|
-
|
|
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
|
|
579
|
+
return { likedIds: [] };
|
|
709
580
|
try {
|
|
710
581
|
const likedIds = await fetchLikedIds(fresh);
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
587
|
+
return { likedIds: [] };
|
|
717
588
|
}
|
|
718
589
|
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const id = String(
|
|
722
|
-
const like =
|
|
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
|
-
|
|
595
|
+
throw badRequest('id is required');
|
|
725
596
|
const yt = await getAuthedInnertube().catch(() => null);
|
|
726
597
|
if (!yt) {
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
throw failed('Like action failed');
|
|
765
632
|
}
|
|
766
633
|
}
|
|
767
|
-
|
|
634
|
+
ctx.apiCache.delete('music-liked-ids');
|
|
768
635
|
logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
|
|
769
|
-
|
|
770
|
-
}
|
|
771
|
-
}
|
|
636
|
+
return { success: true, liked: like };
|
|
637
|
+
}),
|
|
638
|
+
});
|