@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.
- package/README.md +1 -1
- package/client/dist/assets/{AnimeCard-D4qo4289.js → AnimeCard-CJy1SXH3.js} +1 -1
- package/client/dist/assets/{AnimeInfo-BdRRm4So.js → AnimeInfo-BoXlVvZ-.js} +1 -1
- package/client/dist/assets/{AnimeInfoPage-DeJNAKMR.js → AnimeInfoPage-BxbA_7AV.js} +2 -2
- package/client/dist/assets/Asmr--Zc8G9zv.js +2 -0
- package/client/dist/assets/AsmrResetProgressModal-CKYm-8cp.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-3yjZS7s6.js +10 -0
- package/client/dist/assets/Home-rrhOGQcg.js +1 -0
- package/client/dist/assets/Insights-Bay90Yeg.js +1 -0
- package/client/dist/assets/{LibraryListPage-D3hTqTRU.js → LibraryListPage-HzuDavbb.js} +1 -1
- package/client/dist/assets/{ListeningList-BgWsfu40.js → ListeningList-DYcQnnsG.js} +1 -1
- package/client/dist/assets/{Manga-Dn-CYins.js → Manga-D8CT9aO0.js} +1 -1
- package/client/dist/assets/{MangaInfoPage-EmQoK7Bv.js → MangaInfoPage-DGK6CYp1.js} +1 -1
- package/client/dist/assets/MangaReadPage-B9XoDKF9.js +1 -0
- package/client/dist/assets/{MangaResetProgressModal-Dian37PQ.js → MangaResetProgressModal-QjZyIAjl.js} +1 -1
- package/client/dist/assets/Mature-CHt_-Hz5.js +1 -0
- package/client/dist/assets/MediaCard-B5584Cq9.js +1 -0
- package/client/dist/assets/Music-HeLrEZ2d.js +1 -0
- package/client/dist/assets/OptionListMenu-CJ90CdwI.js +1 -0
- package/client/dist/assets/Player-BYtEax_z.js +56 -0
- package/client/dist/assets/PlayerSettings-4-cUAY4B.js +1 -0
- package/client/dist/assets/QueueOptionsButton-Ba0dg_82.js +1 -0
- package/client/dist/assets/{QueueRail-B7DeSFoA.js → QueueRail-O7f4RqjB.js} +1 -1
- package/client/dist/assets/Radio-DPZxQnyD.js +1 -0
- package/client/dist/assets/{ReadingList-BT8kUjCB.js → ReadingList-n6rr8ygy.js} +1 -1
- package/client/dist/assets/{ResetProgressModal-DWeiO-hs.js → ResetProgressModal-CcStlbhP.js} +1 -1
- package/client/dist/assets/Search-C2wrWOdy.js +1 -0
- package/client/dist/assets/Settings-CluUYs8U.js +21 -0
- package/client/dist/assets/{SkeletonGrid-B5loz7ii.js → SkeletonGrid-CLEPY9nP.js} +1 -1
- package/client/dist/assets/Trackers-CTGPxs0V.js +2 -0
- package/client/dist/assets/Tv-BwHlDdi8.js +4 -0
- package/client/dist/assets/Tv-ZEA45gXK.css +1 -0
- package/client/dist/assets/TvInfo-CXnrLekr.js +1 -0
- package/client/dist/assets/TvPopup-BekfiNcX.js +1 -0
- package/client/dist/assets/{TvSearch-BYwCf5kO.js → TvSearch-DuqR-z3v.js} +1 -1
- package/client/dist/assets/{TvWatchlist-BiJU6pKT.js → TvWatchlist-CBvgFrA0.js} +1 -1
- package/client/dist/assets/Watchlist-mj8mTOam.js +1 -0
- package/client/dist/assets/{hls-C11FOWHj.js → hls-Boje3IOE.js} +1 -1
- package/client/dist/assets/index-BXwhFWB_.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DJ7A4B4T.js +1 -0
- package/client/dist/assets/useAsmr-DIwIsJXF.js +1 -0
- package/client/dist/assets/{useAsmrPopup-cT_Uu41e.js → useAsmrPopup-e4zNN9ig.js} +1 -1
- package/client/dist/assets/useMangaLibrary-C9Lci5N4.js +1 -0
- package/client/dist/assets/{useMangaPopup-PnmSctHu.js → useMangaPopup-B8nnpvyi.js} +1 -1
- package/client/dist/assets/useProviders-B5Qr4I1y.js +1 -0
- package/client/dist/assets/useTv-Dm8MCYlL.js +1 -0
- package/client/dist/assets/useTvLibrary-B7jEk2Q7.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 +2 -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 +15 -4
- 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} +235 -295
- 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-D0EGRYsv.js +0 -2
- package/client/dist/assets/AsmrResetProgressModal-Gs0GmVaT.js +0 -1
- package/client/dist/assets/AvSyncCalibrator-D8hqarVB.js +0 -10
- package/client/dist/assets/Home-BnBzjFkW.js +0 -1
- package/client/dist/assets/Insights-i9QgY3nE.js +0 -1
- package/client/dist/assets/MangaReadPage-xJB8K2YU.js +0 -1
- package/client/dist/assets/Mature-DQtXKOXc.js +0 -1
- package/client/dist/assets/MediaCard-DpKoY-1j.js +0 -1
- package/client/dist/assets/Music-BP43Zqmq.js +0 -1
- package/client/dist/assets/OptionListMenu-Bj9ebsW3.js +0 -1
- package/client/dist/assets/Player-O2Cy2GF1.js +0 -56
- package/client/dist/assets/PlayerSettings-DYZ272Yp.js +0 -1
- package/client/dist/assets/QueueOptionsButton-lv26-6jp.js +0 -1
- package/client/dist/assets/Radio-qQBYnVfY.js +0 -1
- package/client/dist/assets/Search-BkvPYN33.js +0 -1
- package/client/dist/assets/Settings-CyZEBF6S.js +0 -21
- package/client/dist/assets/Trackers-JK-aXBYC.js +0 -2
- package/client/dist/assets/Tv-BwXWJxMy.js +0 -4
- package/client/dist/assets/Tv-C2SWcVzN.css +0 -1
- package/client/dist/assets/TvInfo-Dzyq6ucH.js +0 -1
- package/client/dist/assets/TvPopup-PlCU30FO.js +0 -1
- package/client/dist/assets/Watchlist-BOEeGXH9.js +0 -1
- package/client/dist/assets/index-CtoKRNLc.js +0 -178
- package/client/dist/assets/useAnimeInfoData-kr3WOIWH.js +0 -1
- package/client/dist/assets/useAsmr-CB9THf0m.js +0 -1
- package/client/dist/assets/useMangaLibrary-rM6z3_0E.js +0 -1
- package/client/dist/assets/useProviders-B3VB7A9M.js +0 -1
- package/client/dist/assets/useTv-BTUKMH7Q.js +0 -1
- package/client/dist/assets/useTvLibrary-C0sX-ilZ.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')
|
|
@@ -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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
};
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
379
|
+
}),
|
|
380
|
+
signOut: protectedProcedure.mutation(async () => {
|
|
362
381
|
try {
|
|
363
382
|
await signOutMusic();
|
|
364
|
-
|
|
383
|
+
return { success: true };
|
|
365
384
|
}
|
|
366
385
|
catch (err) {
|
|
367
386
|
logger.error({ err }, '[music] sign-out failed');
|
|
368
|
-
|
|
387
|
+
throw failed('Sign-out failed');
|
|
369
388
|
}
|
|
370
|
-
}
|
|
371
|
-
search
|
|
372
|
-
const q = String(
|
|
389
|
+
}),
|
|
390
|
+
search: protectedProcedure.input(musicSearchInput()).query(async ({ ctx, input }) => {
|
|
391
|
+
const q = String(input.q || '').trim();
|
|
373
392
|
if (!q)
|
|
374
|
-
return
|
|
393
|
+
return { tracks: [] };
|
|
375
394
|
const cacheKey = `music-search-${q.toLowerCase()}`;
|
|
376
|
-
const cached =
|
|
395
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
377
396
|
if (cached)
|
|
378
|
-
return
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
const
|
|
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 =
|
|
426
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
518
427
|
if (cached)
|
|
519
|
-
return
|
|
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
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
452
|
+
return { track: null };
|
|
544
453
|
}
|
|
545
|
-
}
|
|
546
|
-
playlist
|
|
547
|
-
const id =
|
|
548
|
-
|
|
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
|
-
|
|
459
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
553
460
|
}
|
|
554
461
|
try {
|
|
555
|
-
|
|
462
|
+
return { tracks: await fetchPlaylistTracks(yt, id) };
|
|
556
463
|
}
|
|
557
464
|
catch (err) {
|
|
558
|
-
logger.warn({ err }, '[music] playlist failed,
|
|
559
|
-
|
|
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
|
|
563
|
-
const yt = await getAuthedInnertube();
|
|
477
|
+
}),
|
|
478
|
+
library: protectedProcedure.query(async () => {
|
|
479
|
+
const yt = await getAuthedInnertube().catch(() => null);
|
|
564
480
|
if (!yt) {
|
|
565
|
-
|
|
481
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
566
482
|
}
|
|
567
483
|
try {
|
|
568
|
-
|
|
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,
|
|
572
|
-
res.json({ tracks: [], playlists: [] });
|
|
490
|
+
logger.warn({ err }, '[music] library failed, retrying with a fresh saved-cookie session');
|
|
573
491
|
}
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
507
|
+
return JSON.parse(JSON.stringify(feed));
|
|
580
508
|
}
|
|
581
509
|
catch (err) {
|
|
582
510
|
logger.error({ err }, '[music] home feed failed');
|
|
583
|
-
|
|
511
|
+
return { contents: [] };
|
|
584
512
|
}
|
|
585
|
-
}
|
|
586
|
-
upnext
|
|
587
|
-
const id =
|
|
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 =
|
|
519
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
596
520
|
if (cached)
|
|
597
|
-
return
|
|
521
|
+
return cached;
|
|
598
522
|
try {
|
|
599
523
|
const yt = await getInnertube();
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
530
|
+
return { tracks: [], playlistId: null };
|
|
618
531
|
}
|
|
619
532
|
}
|
|
620
533
|
try {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
554
|
+
return { tracks: [], playlistId: null };
|
|
637
555
|
}
|
|
638
|
-
}
|
|
639
|
-
|
|
556
|
+
}),
|
|
557
|
+
likes: protectedProcedure.query(async ({ ctx }) => {
|
|
640
558
|
const yt = await getAuthedInnertube().catch(() => null);
|
|
641
559
|
if (!yt) {
|
|
642
|
-
|
|
560
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
643
561
|
}
|
|
644
562
|
const cacheKey = 'music-liked-ids';
|
|
645
|
-
const cached =
|
|
563
|
+
const cached = ctx.apiCache.get(cacheKey);
|
|
646
564
|
if (cached)
|
|
647
|
-
return
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
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,
|
|
656
|
-
|
|
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
|
-
|
|
660
|
-
const id = String(
|
|
661
|
-
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';
|
|
662
594
|
if (!id)
|
|
663
|
-
|
|
595
|
+
throw badRequest('id is required');
|
|
664
596
|
const yt = await getAuthedInnertube().catch(() => null);
|
|
665
597
|
if (!yt) {
|
|
666
|
-
|
|
598
|
+
throw unauthorized('MUSIC_AUTH_REQUIRED');
|
|
667
599
|
}
|
|
668
|
-
|
|
669
|
-
const actions =
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
617
|
+
};
|
|
618
|
+
try {
|
|
619
|
+
await attempt(yt);
|
|
692
620
|
}
|
|
693
621
|
catch (err) {
|
|
694
|
-
logger.
|
|
695
|
-
|
|
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
|
+
});
|