@serifpersia/dango 3.3.4
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/LICENSE +21 -0
- package/README.md +258 -0
- package/client/dist/assets/AnimeCard-B5A20jAI.js +1 -0
- package/client/dist/assets/AnimeInfo-0huPAq0O.js +1 -0
- package/client/dist/assets/AnimeInfo-BFWtHlPC.css +1 -0
- package/client/dist/assets/AnimeInfoPage-Dqr5QH6R.js +2 -0
- package/client/dist/assets/AnimeMetaDetails-BPvv5xIl.js +1 -0
- package/client/dist/assets/AnimeMetaDetails-BwNQnRCd.css +1 -0
- package/client/dist/assets/Asmr-B6ZiqkmU.js +2 -0
- package/client/dist/assets/Asmr-Cq2ua6Zk.css +1 -0
- package/client/dist/assets/Asmr.module-ChUXDZKx.js +1 -0
- package/client/dist/assets/AsmrResetProgressModal-Cqsoo3XC.js +1 -0
- package/client/dist/assets/AvSyncCalibrator-C1_iBSyL.css +1 -0
- package/client/dist/assets/AvSyncCalibrator-C8ZoIwMY.js +10 -0
- package/client/dist/assets/ErrorMessage-CXoRbrLN.css +1 -0
- package/client/dist/assets/ErrorMessage-z4f4WGgP.js +1 -0
- package/client/dist/assets/Home-DtRtTuI9.css +1 -0
- package/client/dist/assets/Home-thJhPfs1.js +1 -0
- package/client/dist/assets/Insights-DKtoVe6L.css +1 -0
- package/client/dist/assets/Insights-ZlgPrzGD.js +1 -0
- package/client/dist/assets/LibraryListPage-CCiee-Ot.js +1 -0
- package/client/dist/assets/ListeningList-ctAz_6Zi.js +1 -0
- package/client/dist/assets/Manga-BI9WMTx5.js +1 -0
- package/client/dist/assets/Manga-Dp_-l1Pw.css +1 -0
- package/client/dist/assets/Manga.module-BcfOdsNP.js +1 -0
- package/client/dist/assets/MangaInfoPage-DcrEr7Sk.js +1 -0
- package/client/dist/assets/MangaReadPage-SYg63m48.js +1 -0
- package/client/dist/assets/MangaResetProgressModal-DX-1PS4h.js +1 -0
- package/client/dist/assets/Map-CXFqXmsZ.css +1 -0
- package/client/dist/assets/Map-Dj51g5zL.js +1 -0
- package/client/dist/assets/Mature-DJmUUtlF.css +1 -0
- package/client/dist/assets/Mature-Dqh_9SA3.js +1 -0
- package/client/dist/assets/MediaCard-Be1ST9oj.js +1 -0
- package/client/dist/assets/MediaCard-DB6Yjgky.css +1 -0
- package/client/dist/assets/Music-BGH_0voi.css +1 -0
- package/client/dist/assets/Music-zsPwT8_5.js +1 -0
- package/client/dist/assets/OptionListMenu-Drc_vybW.js +1 -0
- package/client/dist/assets/Player-B036Ic3m.css +1 -0
- package/client/dist/assets/Player-BEtAYps2.js +56 -0
- package/client/dist/assets/PlayerRedirect-D70t8VUW.js +1 -0
- package/client/dist/assets/PlayerSettings-Bj6SsmQ2.css +1 -0
- package/client/dist/assets/PlayerSettings-Cwoji-P7.js +1 -0
- package/client/dist/assets/QueueOptionsButton-B6BMq66e.js +1 -0
- package/client/dist/assets/QueueOptionsButton-CM15uQn8.css +1 -0
- package/client/dist/assets/QueueRail-BUYEjdaq.css +1 -0
- package/client/dist/assets/QueueRail-VxpUDBSm.js +1 -0
- package/client/dist/assets/Radio-B2Q_Egj7.js +1 -0
- package/client/dist/assets/Radio-CCHNFN7R.css +1 -0
- package/client/dist/assets/Radio.module-DGSZLpKM.js +1 -0
- package/client/dist/assets/ReadingList-O0QPwXu7.js +1 -0
- package/client/dist/assets/ResetProgressModal-CR_ovLAu.js +1 -0
- package/client/dist/assets/Search-C8e2nlDD.js +1 -0
- package/client/dist/assets/Search-Cs45oAwI.css +1 -0
- package/client/dist/assets/Search.module-CMEai-B-.js +1 -0
- package/client/dist/assets/Settings-AuFxQcJF.css +1 -0
- package/client/dist/assets/Settings-BgPWBl2V.js +21 -0
- package/client/dist/assets/SkeletonGrid-CtHKKMLj.js +1 -0
- package/client/dist/assets/SkeletonGrid-DhPYefXr.css +1 -0
- package/client/dist/assets/SynopsisText-BcpwT5iw.js +1 -0
- package/client/dist/assets/SynopsisText-CYv2W3yR.css +1 -0
- package/client/dist/assets/Trackers-Bfoiyuzy.js +2 -0
- package/client/dist/assets/Trackers-D5XBOCDp.css +1 -0
- package/client/dist/assets/Tv-Ca0fCdko.js +3 -0
- package/client/dist/assets/Tv-CuEl3Iol.css +1 -0
- package/client/dist/assets/TvInfo-Bu36xJyO.css +1 -0
- package/client/dist/assets/TvInfo-D_scgw_h.js +1 -0
- package/client/dist/assets/TvPopup-YTI7dEbJ.js +1 -0
- package/client/dist/assets/TvSearch-CxU33UOf.js +1 -0
- package/client/dist/assets/TvWatchlist-B4X1fWEH.js +1 -0
- package/client/dist/assets/VirtualKeyboard-6ZNTym4R.js +1 -0
- package/client/dist/assets/VirtualKeyboard-KIPc5ZGH.css +1 -0
- package/client/dist/assets/Watchlist-BiDEU0iV.css +1 -0
- package/client/dist/assets/Watchlist-P6YMZUdF.js +1 -0
- package/client/dist/assets/Watchlist.module-XGtVQr1d.js +1 -0
- package/client/dist/assets/hls-BOLXTnjL.js +42 -0
- package/client/dist/assets/hls-C4cG9wQw.js +1 -0
- package/client/dist/assets/index-BOKlZEHq.js +30354 -0
- package/client/dist/assets/index-CUm1meqr.css +1 -0
- package/client/dist/assets/index-IZgh1beL.js +178 -0
- package/client/dist/assets/useAnimeInfoData-DeuwDSnI.js +1 -0
- package/client/dist/assets/useAsmr-CbMVRjmV.js +1 -0
- package/client/dist/assets/useAsmrPopup-D91Q3CBg.js +1 -0
- package/client/dist/assets/useIsMobile-Dm_b6Ob3.js +1 -0
- package/client/dist/assets/useMangaLibrary-BPvFb1lU.js +1 -0
- package/client/dist/assets/useMangaPopup-BOL2mQJs.js +1 -0
- package/client/dist/assets/useMatureConsent-CT7-KfzG.js +1 -0
- package/client/dist/assets/useProviders-BsRGONMA.js +1 -0
- package/client/dist/assets/useTv-D37MfTHA.js +1 -0
- package/client/dist/assets/useTvLibrary-Cwxgy_Bg.js +1 -0
- package/client/dist/assets/vendor-core-8R2fFbyN.js +1 -0
- package/client/dist/assets/vendor-icons-CDMuNDeL.js +1 -0
- package/client/dist/assets/vendor-query-BXRECSI6.js +1 -0
- package/client/dist/assets/vendor-ui-CUDCvRS_.js +1 -0
- package/client/dist/av-sync-test.mp4 +0 -0
- package/client/dist/favicon.ico +0 -0
- package/client/dist/index.html +46 -0
- package/client/dist/logo-mochi.png +0 -0
- package/client/dist/logo-sakura.png +0 -0
- package/client/dist/logo.png +0 -0
- package/client/dist/placeholder.svg +4 -0
- package/client/dist/robots.txt +3 -0
- package/orchestrator.js +534 -0
- package/package.json +85 -0
- package/server/dist/app-auth.js +177 -0
- package/server/dist/config.js +175 -0
- package/server/dist/controllers/asmr-library.controller.js +288 -0
- package/server/dist/controllers/auth.controller.js +225 -0
- package/server/dist/controllers/data.controller.js +766 -0
- package/server/dist/controllers/insights.controller.js +198 -0
- package/server/dist/controllers/manga-library.controller.js +296 -0
- package/server/dist/controllers/music.controller.js +698 -0
- package/server/dist/controllers/proxy.controller.js +820 -0
- package/server/dist/controllers/settings.controller.js +609 -0
- package/server/dist/controllers/tv-library.controller.js +310 -0
- package/server/dist/controllers/watchlist.controller.js +1015 -0
- package/server/dist/db.js +145 -0
- package/server/dist/discord-gateway.js +391 -0
- package/server/dist/discord-rpc.js +427 -0
- package/server/dist/github-sync.js +512 -0
- package/server/dist/google.js +748 -0
- package/server/dist/lib/anilist.js +1549 -0
- package/server/dist/lib/anischedule.js +194 -0
- package/server/dist/lib/discord-roles-sync.service.js +130 -0
- package/server/dist/lib/ipc.js +24 -0
- package/server/dist/lib/kitsu.js +544 -0
- package/server/dist/lib/mal.js +934 -0
- package/server/dist/lib/migration.js +329 -0
- package/server/dist/lib/offline-db.js +401 -0
- package/server/dist/lib/temp-ids.js +3 -0
- package/server/dist/lib/tmdb.js +72 -0
- package/server/dist/lib/tracker/anilist-tracker.js +245 -0
- package/server/dist/lib/tracker/sync.service.js +496 -0
- package/server/dist/lib/ytmusic.js +125 -0
- package/server/dist/logger.js +16 -0
- package/server/dist/providers/manga/manga.types.js +7 -0
- package/server/dist/providers/provider.interface.js +1 -0
- package/server/dist/providers/remote-loader.js +574 -0
- package/server/dist/providers/remote-types.js +117 -0
- package/server/dist/providers/title-matching.js +225 -0
- package/server/dist/providers/tv.types.js +1 -0
- package/server/dist/rclone.js +120 -0
- package/server/dist/repositories/asmr.repository.js +123 -0
- package/server/dist/repositories/insights.repository.js +85 -0
- package/server/dist/repositories/library.repository.js +108 -0
- package/server/dist/repositories/mal-cache.repository.js +55 -0
- package/server/dist/repositories/manga.repository.js +138 -0
- package/server/dist/repositories/notifications.repository.js +41 -0
- package/server/dist/repositories/queue.repository.js +61 -0
- package/server/dist/repositories/settings.repository.js +12 -0
- package/server/dist/repositories/shows-meta.repository.js +42 -0
- package/server/dist/repositories/temp-show-ids.repository.js +51 -0
- package/server/dist/repositories/tv.repository.js +151 -0
- package/server/dist/repositories/watched-episodes.repository.js +52 -0
- package/server/dist/repositories/watchlist.repository.js +71 -0
- package/server/dist/request-logger.js +16 -0
- package/server/dist/routes/asmr-library.routes.js +23 -0
- package/server/dist/routes/asmr.routes.js +81 -0
- package/server/dist/routes/auth.routes.js +25 -0
- package/server/dist/routes/data.routes.js +47 -0
- package/server/dist/routes/discord-gateway.routes.js +51 -0
- package/server/dist/routes/insights.routes.js +34 -0
- package/server/dist/routes/lan-auth.routes.js +46 -0
- package/server/dist/routes/manga-library.routes.js +23 -0
- package/server/dist/routes/manga.routes.js +141 -0
- package/server/dist/routes/music.routes.js +20 -0
- package/server/dist/routes/providers.routes.js +17 -0
- package/server/dist/routes/proxy.routes.js +12 -0
- package/server/dist/routes/radio.routes.js +138 -0
- package/server/dist/routes/settings.routes.js +24 -0
- package/server/dist/routes/tracker.routes.js +164 -0
- package/server/dist/routes/translate.routes.js +88 -0
- package/server/dist/routes/tv-library.routes.js +23 -0
- package/server/dist/routes/tv.routes.js +629 -0
- package/server/dist/routes/watchlist.routes.js +341 -0
- package/server/dist/scripts/backfill-episode-duration.js +42 -0
- package/server/dist/server.js +529 -0
- package/server/dist/shipped-defaults.js +7 -0
- package/server/dist/sync-config.js +23 -0
- package/server/dist/sync-payload.js +91 -0
- package/server/dist/sync.js +1128 -0
- package/server/dist/utils/cache.utils.js +25 -0
- package/server/dist/utils/cookie.utils.js +13 -0
- package/server/dist/utils/db-utils.js +9 -0
- package/server/dist/utils/env.utils.js +77 -0
- package/server/dist/utils/http.utils.js +70 -0
- package/server/dist/utils/machine-id.js +40 -0
- package/server/dist/utils/megaplay.utils.js +41 -0
- package/server/dist/utils/request-context.js +2 -0
- package/server/dist/utils/security.utils.js +94 -0
- package/server/package.json +44 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import logger from '../logger.js';
|
|
3
|
+
import { getInnertube, getAuthedInnertube, saveMusicCookie, getMusicAuthStatus, signOutMusic, isParserVariantError, } from '../lib/ytmusic.js';
|
|
4
|
+
const NAME_SUFFIXES = [
|
|
5
|
+
/\s*(- topic)$/i,
|
|
6
|
+
/\s*vevo$/i,
|
|
7
|
+
/\s*[(|[]official(.*?)[)|\]]/i,
|
|
8
|
+
/\s*[(|[]((lyrics?|visualizer|audio)\s*(video)?)[)|\]]/i,
|
|
9
|
+
/\s*[(|[](performance video)[)|\]]/i,
|
|
10
|
+
/\s*[(|[](clip official)[)|\]]/i,
|
|
11
|
+
/\s*[(|[](video version)[)|\]]/i,
|
|
12
|
+
/\s*[(|[](HD|HQ)\s*?(?:audio)?[)|\]]$/i,
|
|
13
|
+
/\s*[(|[](live)[)|\]]$/i,
|
|
14
|
+
/\s*[(|[]4K\s*?(?:upgrade)?[)|\]]$/i,
|
|
15
|
+
];
|
|
16
|
+
export function cleanupMusicName(name) {
|
|
17
|
+
if (!name)
|
|
18
|
+
return name;
|
|
19
|
+
let out = name;
|
|
20
|
+
for (const re of NAME_SUFFIXES)
|
|
21
|
+
out = out.replace(re, '');
|
|
22
|
+
return out.trim();
|
|
23
|
+
}
|
|
24
|
+
function toTrack(node) {
|
|
25
|
+
const n = node;
|
|
26
|
+
const id = n['video_id'] || n['videoId'] || n['id'] || '';
|
|
27
|
+
if (!id || typeof id !== 'string')
|
|
28
|
+
return null;
|
|
29
|
+
const titleObj = n['title'];
|
|
30
|
+
const title = typeof titleObj === 'string' ? titleObj : (titleObj?.text ?? 'Unknown title');
|
|
31
|
+
const artistsRaw = n['artists'] ?? n['authors'] ?? n['subtitle'];
|
|
32
|
+
let artists = '';
|
|
33
|
+
if (Array.isArray(artistsRaw)) {
|
|
34
|
+
artists = artistsRaw
|
|
35
|
+
.map((a) => {
|
|
36
|
+
const item = a;
|
|
37
|
+
return item.name || item.text || item.title || '';
|
|
38
|
+
})
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join(', ');
|
|
41
|
+
}
|
|
42
|
+
else if (typeof artistsRaw === 'string') {
|
|
43
|
+
artists = artistsRaw;
|
|
44
|
+
}
|
|
45
|
+
else if (artistsRaw && typeof artistsRaw === 'object') {
|
|
46
|
+
artists = artistsRaw.text ?? '';
|
|
47
|
+
}
|
|
48
|
+
const thumbsRaw = (n['thumbnails'] ?? n['thumbnail']);
|
|
49
|
+
const thumbnails = Array.isArray(thumbsRaw)
|
|
50
|
+
? thumbsRaw
|
|
51
|
+
: Array.isArray(thumbsRaw?.thumbnails)
|
|
52
|
+
? thumbsRaw.thumbnails
|
|
53
|
+
: [];
|
|
54
|
+
const albumText = textOf(n['album']);
|
|
55
|
+
const durationText = textOf(n['duration']);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
title: cleanupMusicName(String(title)),
|
|
59
|
+
artists: cleanupMusicName(artists),
|
|
60
|
+
album: albumText ? cleanupMusicName(albumText) : undefined,
|
|
61
|
+
duration: durationText || undefined,
|
|
62
|
+
thumbnails,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function textOf(value) {
|
|
66
|
+
if (!value)
|
|
67
|
+
return '';
|
|
68
|
+
if (typeof value === 'string')
|
|
69
|
+
return value;
|
|
70
|
+
const obj = value;
|
|
71
|
+
if (typeof obj.text === 'string')
|
|
72
|
+
return obj.text;
|
|
73
|
+
if (Array.isArray(obj.runs))
|
|
74
|
+
return obj.runs.map((r) => r.text || '').join('');
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
function thumbsOf(value) {
|
|
78
|
+
if (!value)
|
|
79
|
+
return [];
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return value
|
|
82
|
+
.filter((t) => t && typeof t.url === 'string')
|
|
83
|
+
.map((t) => ({ url: t.url, width: t.width, height: t.height }));
|
|
84
|
+
}
|
|
85
|
+
const obj = value;
|
|
86
|
+
if (Array.isArray(obj.thumbnails))
|
|
87
|
+
return thumbsOf(obj.thumbnails);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
function nodeType(node) {
|
|
91
|
+
const n = node;
|
|
92
|
+
if (typeof n?.type === 'string')
|
|
93
|
+
return n.type;
|
|
94
|
+
return n?.constructor?.name ?? '';
|
|
95
|
+
}
|
|
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
|
+
function upnextToTrack(node, trustLike = false) {
|
|
100
|
+
const kind = nodeType(node);
|
|
101
|
+
if (kind === 'AutomixPreviewVideo')
|
|
102
|
+
return null;
|
|
103
|
+
const n = node;
|
|
104
|
+
const video = kind === 'PlaylistPanelVideoWrapper'
|
|
105
|
+
? (n['primary'] ?? null)
|
|
106
|
+
: n;
|
|
107
|
+
if (!video || typeof video !== 'object')
|
|
108
|
+
return null;
|
|
109
|
+
const id = video['video_id'];
|
|
110
|
+
if (typeof id !== 'string' || !id)
|
|
111
|
+
return null;
|
|
112
|
+
const artistsRaw = video['artists'];
|
|
113
|
+
let artists = '';
|
|
114
|
+
if (Array.isArray(artistsRaw)) {
|
|
115
|
+
artists = artistsRaw
|
|
116
|
+
.map((a) => a?.name || '')
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.join(', ');
|
|
119
|
+
}
|
|
120
|
+
if (!artists && typeof video['author'] === 'string')
|
|
121
|
+
artists = video['author'];
|
|
122
|
+
const albumRaw = video['album'];
|
|
123
|
+
const durationRaw = video['duration'];
|
|
124
|
+
const durationText = typeof durationRaw === 'string' ? durationRaw : (durationRaw?.text ?? '');
|
|
125
|
+
let liked = null;
|
|
126
|
+
if (trustLike) {
|
|
127
|
+
for (const menu of [video['menu'], node['menu']]) {
|
|
128
|
+
let found = null;
|
|
129
|
+
for (const entry of asMenuItems(menu)) {
|
|
130
|
+
if (nodeType(entry) !== 'ToggleMenuServiceItem')
|
|
131
|
+
continue;
|
|
132
|
+
const icon = entry['icon_type'];
|
|
133
|
+
if (icon !== 'FAVORITE' && icon !== 'UNFAVORITE')
|
|
134
|
+
continue;
|
|
135
|
+
found = icon === 'UNFAVORITE';
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
if (found !== null) {
|
|
139
|
+
liked = found;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
id,
|
|
146
|
+
title: cleanupMusicName(textOf(video['title']) || 'Unknown title'),
|
|
147
|
+
artists: cleanupMusicName(artists || 'Unknown artist'),
|
|
148
|
+
album: albumRaw?.name ? cleanupMusicName(albumRaw.name) : undefined,
|
|
149
|
+
duration: durationText || undefined,
|
|
150
|
+
thumbnails: thumbsOf(video['thumbnail'] ?? video['thumbnails']),
|
|
151
|
+
...(liked !== null ? { liked } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function asMenuItems(menu) {
|
|
155
|
+
if (!menu || typeof menu !== 'object')
|
|
156
|
+
return [];
|
|
157
|
+
const items = menu.items;
|
|
158
|
+
return Array.isArray(items) ? items : [];
|
|
159
|
+
}
|
|
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
|
+
function findLikeToggle(item, videoId) {
|
|
165
|
+
const result = { like: null, unlike: null };
|
|
166
|
+
const kind = nodeType(item);
|
|
167
|
+
const n = item;
|
|
168
|
+
const video = kind === 'PlaylistPanelVideoWrapper'
|
|
169
|
+
? (n['primary'] ?? null)
|
|
170
|
+
: n;
|
|
171
|
+
if (!video || video['video_id'] !== videoId)
|
|
172
|
+
return result;
|
|
173
|
+
const menus = [video['menu'], n['menu']];
|
|
174
|
+
for (const menu of menus) {
|
|
175
|
+
for (const entry of asMenuItems(menu)) {
|
|
176
|
+
if (nodeType(entry) !== 'ToggleMenuServiceItem')
|
|
177
|
+
continue;
|
|
178
|
+
const icon = entry['icon_type'];
|
|
179
|
+
if (icon !== 'FAVORITE' && icon !== 'UNFAVORITE')
|
|
180
|
+
continue;
|
|
181
|
+
for (const key of ['default_endpoint', 'toggled_endpoint']) {
|
|
182
|
+
const ep = entry[key];
|
|
183
|
+
if (typeof ep?.call !== 'function')
|
|
184
|
+
continue;
|
|
185
|
+
const status = ep.payload?.status;
|
|
186
|
+
if (status === 'LIKE' && !result.like)
|
|
187
|
+
result.like = ep;
|
|
188
|
+
else if (status === 'INDIFFERENT' && !result.unlike)
|
|
189
|
+
result.unlike = ep;
|
|
190
|
+
}
|
|
191
|
+
if (result.like && result.unlike)
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
async function fetchPlaylistTracks(yt, id, cap = 500) {
|
|
198
|
+
const tracks = [];
|
|
199
|
+
let page = (await yt.music.getPlaylist(id));
|
|
200
|
+
for (let fetches = 0; fetches < 6; fetches++) {
|
|
201
|
+
for (const item of (page.items ?? page.contents ?? []).slice(0, cap - tracks.length)) {
|
|
202
|
+
const track = toTrack(item);
|
|
203
|
+
if (track)
|
|
204
|
+
tracks.push(track);
|
|
205
|
+
}
|
|
206
|
+
if (tracks.length >= cap)
|
|
207
|
+
break;
|
|
208
|
+
if (!page.has_continuation || typeof page.getContinuation !== 'function')
|
|
209
|
+
break;
|
|
210
|
+
try {
|
|
211
|
+
page = (await page.getContinuation());
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
logger.warn({ err }, '[music] playlist continuation failed');
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return tracks;
|
|
219
|
+
}
|
|
220
|
+
async function readLibrary(yt) {
|
|
221
|
+
let lib;
|
|
222
|
+
try {
|
|
223
|
+
lib = (await yt.music.getLibrary());
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (!isParserVariantError(err))
|
|
227
|
+
throw err;
|
|
228
|
+
logger.warn('[music] library landing hit a layout variant, falling back to Liked playlist');
|
|
229
|
+
try {
|
|
230
|
+
const tracks = await fetchPlaylistTracks(yt, 'LM');
|
|
231
|
+
return { tracks, playlists: [] };
|
|
232
|
+
}
|
|
233
|
+
catch (fallbackErr) {
|
|
234
|
+
logger.warn({ err: fallbackErr }, '[music] liked playlist fallback failed');
|
|
235
|
+
return { tracks: [], playlists: [] };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const gridItems = [];
|
|
239
|
+
for (const section of lib.contents ?? []) {
|
|
240
|
+
const items = section.items ?? section.contents ?? [];
|
|
241
|
+
gridItems.push(...items);
|
|
242
|
+
}
|
|
243
|
+
const playlists = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
for (const item of gridItems) {
|
|
246
|
+
const n = item;
|
|
247
|
+
const id = (typeof n['playlist_id'] === 'string' && n['playlist_id']) ||
|
|
248
|
+
(typeof n['playlistId'] === 'string' && n['playlistId']) ||
|
|
249
|
+
(typeof n['id'] === 'string' && n['id']) ||
|
|
250
|
+
'';
|
|
251
|
+
const title = textOf(n['title']);
|
|
252
|
+
const subtitle = textOf(n['subtitle']);
|
|
253
|
+
if (!id || !title || seen.has(id))
|
|
254
|
+
continue;
|
|
255
|
+
if (!/playlist|mix/i.test(subtitle) && title !== 'Liked Music')
|
|
256
|
+
continue;
|
|
257
|
+
seen.add(id);
|
|
258
|
+
playlists.push({
|
|
259
|
+
id,
|
|
260
|
+
title,
|
|
261
|
+
subtitle: subtitle || undefined,
|
|
262
|
+
thumbnails: thumbsOf(n['thumbnails'] ?? n['thumbnail']),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const tracks = [];
|
|
266
|
+
const liked = playlists.find((p) => p.title === 'Liked Music');
|
|
267
|
+
if (liked) {
|
|
268
|
+
try {
|
|
269
|
+
tracks.push(...(await fetchPlaylistTracks(yt, liked.id)));
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
logger.warn({ err }, '[music] liked playlist fetch failed');
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
logger.info(`[music] library parsed tracks=${tracks.length} playlists=${playlists.length} gridItems=${gridItems.length}`);
|
|
276
|
+
return { tracks, playlists };
|
|
277
|
+
}
|
|
278
|
+
const UPSTREAM_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
279
|
+
const audioCache = new Map();
|
|
280
|
+
const AUDIO_CACHE_MS = 5 * 60 * 1000;
|
|
281
|
+
async function decipherAudio(videoId) {
|
|
282
|
+
const cached = audioCache.get(videoId);
|
|
283
|
+
if (cached && Date.now() - cached.fetchedAt < AUDIO_CACHE_MS)
|
|
284
|
+
return cached;
|
|
285
|
+
const authed = await getAuthedInnertube().catch(() => null);
|
|
286
|
+
const yt = authed ?? (await getInnertube());
|
|
287
|
+
const info = await yt.music.getInfo(videoId);
|
|
288
|
+
const streaming = info.streaming_data;
|
|
289
|
+
const formats = streaming?.adaptive_formats ?? [];
|
|
290
|
+
const sorted = [...formats]
|
|
291
|
+
.filter((f) => f.has_audio && !f.has_video)
|
|
292
|
+
.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
|
|
293
|
+
const player = yt.session.player;
|
|
294
|
+
for (const fmt of sorted) {
|
|
295
|
+
try {
|
|
296
|
+
const url = await fmt.decipher(player);
|
|
297
|
+
if (url && url.startsWith('http')) {
|
|
298
|
+
const entry = { url, mimeType: fmt.mime_type, fetchedAt: Date.now() };
|
|
299
|
+
audioCache.set(videoId, entry);
|
|
300
|
+
return entry;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// ignore
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw new Error('No stream available');
|
|
308
|
+
}
|
|
309
|
+
const PASSTHROUGH_HEADERS = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
|
|
310
|
+
export class MusicController {
|
|
311
|
+
cache;
|
|
312
|
+
constructor(cache) {
|
|
313
|
+
this.cache = cache;
|
|
314
|
+
}
|
|
315
|
+
getAuthStatus = (_req, res) => {
|
|
316
|
+
res.json(getMusicAuthStatus());
|
|
317
|
+
};
|
|
318
|
+
startAuth = async (req, res) => {
|
|
319
|
+
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
|
+
await saveMusicCookie(cookie);
|
|
338
|
+
const yt = await getAuthedInnertube();
|
|
339
|
+
if (yt) {
|
|
340
|
+
try {
|
|
341
|
+
const lib = await readLibrary(yt);
|
|
342
|
+
return res.json({
|
|
343
|
+
success: true,
|
|
344
|
+
tracks: lib.tracks.length,
|
|
345
|
+
playlists: lib.playlists.length,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// ignore
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
res.json({ success: true, tracks: 0, playlists: 0 });
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
logger.error({ err }, '[music] cookie sign-in failed');
|
|
356
|
+
res.status(401).json({
|
|
357
|
+
error: 'Cookie rejected by YouTube Music. Copy a fresh Cookie header from music.youtube.com.',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
signOut = async (_req, res) => {
|
|
362
|
+
try {
|
|
363
|
+
await signOutMusic();
|
|
364
|
+
res.json({ success: true });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
logger.error({ err }, '[music] sign-out failed');
|
|
368
|
+
res.status(500).json({ error: 'Sign-out failed' });
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
search = async (req, res) => {
|
|
372
|
+
const q = String(req.query.q || '').trim();
|
|
373
|
+
if (!q)
|
|
374
|
+
return res.json({ tracks: [] });
|
|
375
|
+
const cacheKey = `music-search-${q.toLowerCase()}`;
|
|
376
|
+
const cached = this.cache.get(cacheKey);
|
|
377
|
+
if (cached)
|
|
378
|
+
return res.json({ tracks: cached });
|
|
379
|
+
try {
|
|
380
|
+
const yt = await getInnertube();
|
|
381
|
+
const result = await yt.music.search(q, { type: 'song' });
|
|
382
|
+
const tracks = [];
|
|
383
|
+
const contents = result.contents ?? [];
|
|
384
|
+
for (const section of contents) {
|
|
385
|
+
const items = section.contents ?? [section];
|
|
386
|
+
for (const item of items) {
|
|
387
|
+
const track = toTrack(item);
|
|
388
|
+
if (track)
|
|
389
|
+
tracks.push(track);
|
|
390
|
+
if (tracks.length >= 25)
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
if (tracks.length >= 25)
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
this.cache.set(cacheKey, tracks, 300);
|
|
397
|
+
res.json({ tracks });
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
logger.error({ err }, '[music] search failed');
|
|
401
|
+
res.json({ tracks: [] });
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
stream = async (req, res) => {
|
|
405
|
+
const videoId = String(req.query.videoId || req.query.id || '').trim();
|
|
406
|
+
if (!videoId)
|
|
407
|
+
return res.status(400).json({ error: 'videoId is required' });
|
|
408
|
+
try {
|
|
409
|
+
const entry = await decipherAudio(videoId);
|
|
410
|
+
res.json({ url: entry.url, mimeType: entry.mimeType });
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
logger.error({ err }, '[music] stream failed');
|
|
414
|
+
res.status(502).json({ error: 'Stream lookup failed' });
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
audio = async (req, res) => {
|
|
418
|
+
const videoId = String(req.query.videoId || req.query.id || '').trim();
|
|
419
|
+
if (!videoId)
|
|
420
|
+
return res.status(400).json({ error: 'videoId is required' });
|
|
421
|
+
const aborter = new AbortController();
|
|
422
|
+
res.on('close', () => {
|
|
423
|
+
if (!res.writableEnded)
|
|
424
|
+
aborter.abort();
|
|
425
|
+
});
|
|
426
|
+
try {
|
|
427
|
+
let entry;
|
|
428
|
+
try {
|
|
429
|
+
entry = await decipherAudio(videoId);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
return res.status(502).json({ error: 'No stream available' });
|
|
433
|
+
}
|
|
434
|
+
const timeout = AbortSignal.timeout(30000);
|
|
435
|
+
const signal = aborter.signal.aborted
|
|
436
|
+
? aborter.signal
|
|
437
|
+
: AbortSignal.any([aborter.signal, timeout]);
|
|
438
|
+
let upstream;
|
|
439
|
+
try {
|
|
440
|
+
upstream = await fetch(entry.url, {
|
|
441
|
+
headers: {
|
|
442
|
+
'User-Agent': UPSTREAM_UA,
|
|
443
|
+
Referer: 'https://music.youtube.com/',
|
|
444
|
+
Origin: 'https://music.youtube.com/',
|
|
445
|
+
Range: typeof req.headers.range === 'string' ? req.headers.range : 'bytes=0-',
|
|
446
|
+
},
|
|
447
|
+
signal,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (aborter.signal.aborted)
|
|
452
|
+
return;
|
|
453
|
+
logger.error({ err }, '[music] audio upstream failed');
|
|
454
|
+
if (!res.headersSent)
|
|
455
|
+
res.status(502).json({ error: 'Upstream error' });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (upstream.status !== 200 && upstream.status !== 206) {
|
|
459
|
+
try {
|
|
460
|
+
await upstream.body?.cancel();
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// ignore
|
|
464
|
+
}
|
|
465
|
+
audioCache.delete(videoId);
|
|
466
|
+
if (!res.headersSent)
|
|
467
|
+
res.status(502).json({ error: 'Upstream error' });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
res.status(upstream.status);
|
|
471
|
+
for (const name of PASSTHROUGH_HEADERS) {
|
|
472
|
+
const value = upstream.headers.get(name);
|
|
473
|
+
if (value)
|
|
474
|
+
res.set(name === 'content-type' ? 'Content-Type' : name, value);
|
|
475
|
+
}
|
|
476
|
+
if (!res.get('Content-Type'))
|
|
477
|
+
res.set('Content-Type', entry.mimeType || 'audio/webm');
|
|
478
|
+
res.set('Accept-Ranges', 'bytes');
|
|
479
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
480
|
+
if (!upstream.body) {
|
|
481
|
+
if (!res.headersSent)
|
|
482
|
+
res.status(502).json({ error: 'Upstream error' });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const nodeStream = Readable.fromWeb(upstream.body);
|
|
487
|
+
nodeStream.on('error', () => {
|
|
488
|
+
try {
|
|
489
|
+
if (!res.writableEnded)
|
|
490
|
+
res.destroy();
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// ignore
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
nodeStream.pipe(res);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
logger.error({ err }, '[music] audio pipe failed');
|
|
500
|
+
if (!res.headersSent)
|
|
501
|
+
res.status(502).json({ error: 'Upstream error' });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
if (aborter.signal.aborted)
|
|
506
|
+
return;
|
|
507
|
+
logger.error({ err }, '[music] audio failed');
|
|
508
|
+
if (!res.headersSent)
|
|
509
|
+
res.status(502).json({ error: 'Stream lookup failed' });
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
track = async (req, res) => {
|
|
513
|
+
const id = String(req.query.id || '').trim();
|
|
514
|
+
if (!id)
|
|
515
|
+
return res.status(400).json({ error: 'id is required', track: null });
|
|
516
|
+
const cacheKey = `music-track-${id.toLowerCase()}`;
|
|
517
|
+
const cached = this.cache.get(cacheKey);
|
|
518
|
+
if (cached)
|
|
519
|
+
return res.json({ track: cached });
|
|
520
|
+
try {
|
|
521
|
+
const yt = await getInnertube();
|
|
522
|
+
const info = await yt.music.getInfo(id);
|
|
523
|
+
const basic = info.basic_info;
|
|
524
|
+
if (!basic?.title)
|
|
525
|
+
return res.json({ track: null });
|
|
526
|
+
const durationSec = typeof basic.duration === 'number' && Number.isFinite(basic.duration)
|
|
527
|
+
? Math.max(Math.round(basic.duration), 0)
|
|
528
|
+
: 0;
|
|
529
|
+
const track = {
|
|
530
|
+
id,
|
|
531
|
+
title: cleanupMusicName(String(basic.title)),
|
|
532
|
+
artists: cleanupMusicName(String(basic.author || 'Unknown artist')),
|
|
533
|
+
duration: durationSec
|
|
534
|
+
? `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, '0')}`
|
|
535
|
+
: undefined,
|
|
536
|
+
thumbnails: Array.isArray(basic.thumbnail) ? basic.thumbnail : [],
|
|
537
|
+
};
|
|
538
|
+
this.cache.set(cacheKey, track, 300);
|
|
539
|
+
res.json({ track });
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
logger.error({ err }, '[music] track lookup failed');
|
|
543
|
+
res.json({ track: null });
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
playlist = async (req, res) => {
|
|
547
|
+
const id = String(req.query.id || '').trim();
|
|
548
|
+
if (!id)
|
|
549
|
+
return res.status(400).json({ error: 'id is required', tracks: [] });
|
|
550
|
+
const yt = await getAuthedInnertube();
|
|
551
|
+
if (!yt) {
|
|
552
|
+
return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [] });
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
res.json({ tracks: await fetchPlaylistTracks(yt, id) });
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
logger.warn({ err }, '[music] playlist failed, returning empty');
|
|
559
|
+
res.json({ tracks: [] });
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
library = async (_req, res) => {
|
|
563
|
+
const yt = await getAuthedInnertube();
|
|
564
|
+
if (!yt) {
|
|
565
|
+
return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', tracks: [], playlists: [] });
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
res.json(await readLibrary(yt));
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
logger.warn({ err }, '[music] library failed, returning empty');
|
|
572
|
+
res.json({ tracks: [], playlists: [] });
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
home = async (_req, res) => {
|
|
576
|
+
try {
|
|
577
|
+
const yt = await getInnertube();
|
|
578
|
+
const feed = await yt.music.getHomeFeed();
|
|
579
|
+
res.json(feed);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
logger.error({ err }, '[music] home feed failed');
|
|
583
|
+
res.json({ contents: [] });
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
upnext = async (req, res) => {
|
|
587
|
+
const id = String(req.query.id || '').trim();
|
|
588
|
+
if (!id)
|
|
589
|
+
return res.status(400).json({ error: 'id is required', tracks: [] });
|
|
590
|
+
// Signed-in sessions get fresh results with per-track like state; public
|
|
591
|
+
// results are cacheable and carry no like state.
|
|
592
|
+
const authed = await getAuthedInnertube().catch(() => null);
|
|
593
|
+
if (!authed) {
|
|
594
|
+
const cacheKey = `music-upnext-${id.toLowerCase()}`;
|
|
595
|
+
const cached = this.cache.get(cacheKey);
|
|
596
|
+
if (cached)
|
|
597
|
+
return res.json(cached);
|
|
598
|
+
try {
|
|
599
|
+
const yt = await getInnertube();
|
|
600
|
+
const panel = await yt.music.getUpNext(id);
|
|
601
|
+
const tracks = [];
|
|
602
|
+
const contents = panel.contents ?? [];
|
|
603
|
+
for (const item of contents) {
|
|
604
|
+
const track = upnextToTrack(item);
|
|
605
|
+
if (track)
|
|
606
|
+
tracks.push(track);
|
|
607
|
+
if (tracks.length >= 50)
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
const playlistId = panel.playlist_id ?? null;
|
|
611
|
+
const payload = { tracks, playlistId };
|
|
612
|
+
this.cache.set(cacheKey, payload, 300);
|
|
613
|
+
return res.json(payload);
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
logger.error({ err }, '[music] upnext failed');
|
|
617
|
+
return res.json({ tracks: [], playlistId: null });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const panel = await authed.music.getUpNext(id);
|
|
622
|
+
const tracks = [];
|
|
623
|
+
const contents = panel.contents ?? [];
|
|
624
|
+
for (const item of contents) {
|
|
625
|
+
const track = upnextToTrack(item, true);
|
|
626
|
+
if (track)
|
|
627
|
+
tracks.push(track);
|
|
628
|
+
if (tracks.length >= 50)
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
const playlistId = panel.playlist_id ?? null;
|
|
632
|
+
res.json({ tracks, playlistId });
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
logger.error({ err }, '[music] upnext failed');
|
|
636
|
+
res.json({ tracks: [], playlistId: null });
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
likedIds = async (_req, res) => {
|
|
640
|
+
const yt = await getAuthedInnertube().catch(() => null);
|
|
641
|
+
if (!yt) {
|
|
642
|
+
return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED', likedIds: [] });
|
|
643
|
+
}
|
|
644
|
+
const cacheKey = 'music-liked-ids';
|
|
645
|
+
const cached = this.cache.get(cacheKey);
|
|
646
|
+
if (cached)
|
|
647
|
+
return res.json({ likedIds: cached });
|
|
648
|
+
try {
|
|
649
|
+
const tracks = await fetchPlaylistTracks(yt, 'LM', 500);
|
|
650
|
+
const likedIds = tracks.map((t) => t.id);
|
|
651
|
+
this.cache.set(cacheKey, likedIds, 120);
|
|
652
|
+
res.json({ likedIds });
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
logger.warn({ err }, '[music] liked ids failed, returning empty');
|
|
656
|
+
res.json({ likedIds: [] });
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
rate = async (req, res) => {
|
|
660
|
+
const id = String(req.body?.id ?? req.query.id ?? '').trim();
|
|
661
|
+
const like = req.body?.like === true || req.body?.like === 'true';
|
|
662
|
+
if (!id)
|
|
663
|
+
return res.status(400).json({ error: 'id is required' });
|
|
664
|
+
const yt = await getAuthedInnertube().catch(() => null);
|
|
665
|
+
if (!yt) {
|
|
666
|
+
return res.status(401).json({ error: 'MUSIC_AUTH_REQUIRED' });
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const actions = yt.actions ??
|
|
670
|
+
yt.session?.actions;
|
|
671
|
+
if (!actions)
|
|
672
|
+
throw new Error('Actions unavailable');
|
|
673
|
+
// Only ever call the toggle endpoints YouTube Music itself returns for
|
|
674
|
+
// this track (payload status LIKE/INDIFFERENT). No hand-built endpoints:
|
|
675
|
+
// raw video ids can differ from the canonical song id and a direct
|
|
676
|
+
// /like call would orphan entries in Liked Music.
|
|
677
|
+
const panel = await yt.music.getUpNext(id);
|
|
678
|
+
const contents = panel.contents ?? [];
|
|
679
|
+
let endpoint = null;
|
|
680
|
+
for (const item of contents) {
|
|
681
|
+
const toggle = findLikeToggle(item, id);
|
|
682
|
+
endpoint = like ? toggle.like : toggle.unlike;
|
|
683
|
+
if (endpoint)
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
if (!endpoint)
|
|
687
|
+
throw new Error('Like toggle not found for track');
|
|
688
|
+
await endpoint.call(actions);
|
|
689
|
+
this.cache.delete('music-liked-ids');
|
|
690
|
+
logger.info(`[music] rate id=${id} liked=${like} via=toggle`);
|
|
691
|
+
res.json({ success: true, liked: like });
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
logger.error({ err }, '[music] rate failed');
|
|
695
|
+
res.status(502).json({ error: 'Like action failed' });
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}
|