playlist-admin 2.6.80 → 2.6.82

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.
@@ -1,267 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { walkDir, stemOf, relativePath } from './util.js';
4
-
5
- // Port of lib/services/spotify_scraper.dart
6
- const cn2en = {
7
- '郭靜': 'Claire Kuo', '蔡依林': 'Jolin Tsai', '盧廣仲': 'Crowd Lu',
8
- '曾沛慈': 'Pets Tseng', '王艷薇': 'Evangeline Wong', '胡恂舞': 'Sabrina Hu',
9
- '周興哲': 'Eric Chou', '孫盛希': 'Shi Shi', '文慧如': 'Boon Hui Lu',
10
- '陳忻玥': 'Vicky Chen', '邱鋒澤': 'Feng Ze', '艾薇': 'Ivy',
11
- '幻藍小熊': 'GENBLUE', '利比': 'LBI', '連穎': 'Erin',
12
- '李芷婷': 'Eleanor', '白安': 'Ann Bai', '王詩安': 'Diana Wang',
13
- '陳威全': 'Ethan', '持修': 'Chih Siou', '羅志祥': 'Show Luo',
14
- '陳零九': 'Nine Chen', '九澤CP': 'Nine Ze CP',
15
- '告五人': 'Accusefive', '理想混蛋': 'Bestards',
16
- '魏如萱': 'Waa Wei', '魏如昀': 'Queen Wei',
17
- };
18
-
19
- const toEnglish = (name) => cn2en[name] || name;
20
-
21
- function englishDisplayName(raw) {
22
- if (!raw.includes(' - ')) return raw;
23
- const idx = raw.lastIndexOf(' - ');
24
- return `${raw.substring(0, idx)} - ${toEnglish(raw.substring(idx + 3).trim())}`;
25
- }
26
-
27
- function getPath(obj, keys) {
28
- let current = obj;
29
- for (const k of keys) {
30
- if (current && typeof current === 'object' && current[k] && typeof current[k] === 'object') {
31
- current = current[k];
32
- } else {
33
- return null;
34
- }
35
- }
36
- return current;
37
- }
38
-
39
- function sanitize(name) {
40
- return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, ' ').trim();
41
- }
42
-
43
- function stripControlChars(s) {
44
- return s.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff]/g, '').trim();
45
- }
46
-
47
- export class SpotifyScraper {
48
- constructor({ log, playlistsPath, libraryPath = '' }) {
49
- this.log = log;
50
- this.playlistsPath = playlistsPath;
51
- this.libraryPath = libraryPath;
52
- this._mp3Index = null;
53
- }
54
-
55
- async _buildIndex() {
56
- if (this._mp3Index || !this.libraryPath) return;
57
- this.log(' 掃描音樂庫建立檔案索引…');
58
- const mp3 = new Map();
59
- const dirs = [this.libraryPath, path.join(this.libraryPath, 'mp3'), path.join(this.libraryPath, 'flac')];
60
- for (const dir of dirs) {
61
- if (!fs.existsSync(dir)) continue;
62
- const files = await walkDir(dir, { filter: (f) => f.toLowerCase().endsWith('.mp3') });
63
- for (const f of files) {
64
- const low = f.toLowerCase();
65
- if (low.includes('podcast_downloads') || low.includes('podcast_rag')) continue;
66
- const stem = stemOf(f).toLowerCase();
67
- if (!mp3.has(stem)) mp3.set(stem, f);
68
- }
69
- }
70
- this._mp3Index = mp3;
71
- this.log(` 音檔索引完成: MP3 ${mp3.size}`);
72
- }
73
-
74
- _findAudioFile(trackName) {
75
- const ix = this._mp3Index;
76
- if (!ix) return null;
77
- const stem = trackName.toLowerCase().trim();
78
- if (ix.has(stem)) return ix.get(stem);
79
- if (stem.includes(' - ')) {
80
- const parts = stem.split(' - ');
81
- if (parts.length >= 2) {
82
- const reversed = `${parts[1]} - ${parts[0]}`;
83
- if (ix.has(reversed)) return ix.get(reversed);
84
- const titlePart = parts[0].trim();
85
- if (titlePart.length >= 2) {
86
- for (const k of ix.keys()) {
87
- if (k.startsWith(titlePart) || k.includes(` ${titlePart} `)) {
88
- return ix.get(k);
89
- }
90
- }
91
- }
92
- }
93
- }
94
- return null;
95
- }
96
-
97
- async scrapeAll(urls, config) {
98
- const plNames = [];
99
- let processed = 0;
100
- for (const url of urls) {
101
- processed++;
102
- this.log(`[${processed}/${urls.length}] 處理: ${url}`);
103
- try {
104
- const name = await this._scrapeOne(url, config);
105
- if (name != null) plNames.push(name);
106
- } catch (e) {
107
- this.log(` 錯誤: ${e.message || e}`);
108
- }
109
- }
110
- return plNames;
111
- }
112
-
113
- async _scrapeOne(url, config) {
114
- const spId = url.split('playlist/').pop().split('?')[0];
115
- const embedUrl = `https://open.spotify.com/embed/playlist/${spId}`;
116
- this.log(' 連線到 Spotify Embed…');
117
- const resp = await fetch(embedUrl, {
118
- headers: {
119
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
120
- 'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.5',
121
- },
122
- signal: AbortSignal.timeout(60000),
123
- });
124
- if (resp.status !== 200) {
125
- this.log(` HTTP ${resp.status}`);
126
- return null;
127
- }
128
- const html = await resp.text();
129
-
130
- let plName = null;
131
- const tracks = [];
132
-
133
- // Try __NEXT_DATA__ first
134
- const nextMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
135
- if (nextMatch) {
136
- try {
137
- const data = JSON.parse(nextMatch[1]);
138
- const entity = getPath(data, ['props', 'pageProps', 'state', 'data', 'entity']);
139
- if (entity) {
140
- plName = entity.name ? stripControlChars(String(entity.name)) : null;
141
- const trackList = entity.trackList || entity.tracks;
142
- if (Array.isArray(trackList)) {
143
- for (const item of trackList) {
144
- const track = (item && typeof item === 'object' && item.track) || item;
145
- if (!track) continue;
146
- const name = track.name || track.title;
147
- if (!name) continue;
148
- const artists = Array.isArray(track.artists)
149
- ? track.artists.map((a) => a?.name).filter(Boolean).join(', ')
150
- : track.subtitle;
151
- const displayName = artists ? `${name} - ${artists}` : name;
152
- tracks.push(englishDisplayName(displayName));
153
- this._saveTrackCache(englishDisplayName(displayName), String(name), toEnglish((artists || '').trim()), track, config);
154
- }
155
- }
156
- }
157
- } catch {}
158
- }
159
-
160
- // Fallback: old script[type="application/json"]
161
- if (!plName || tracks.length === 0) {
162
- const scriptRe = /<script type="application\/json"[^>]*>([\s\S]*?)<\/script>/g;
163
- let m;
164
- while ((m = scriptRe.exec(html)) != null) {
165
- try {
166
- const json = JSON.parse(m[1]);
167
- const state = json.state;
168
- if (!state || !state.playlist) continue;
169
- const playlist = state.playlist;
170
- plName = playlist.name ? stripControlChars(String(playlist.name)) : null;
171
- const items = playlist.items;
172
- if (!Array.isArray(items)) continue;
173
- for (const item of items) {
174
- const track = item.track;
175
- if (!track) continue;
176
- const artists = Array.isArray(track.artists)
177
- ? track.artists.map((a) => a?.name).filter(Boolean).join(', ')
178
- : '';
179
- const title = track.name || '';
180
- const displayName = artists ? `${title} - ${artists}` : title;
181
- tracks.push(englishDisplayName(displayName));
182
- this._saveTrackCache(englishDisplayName(displayName), String(title), toEnglish(artists.trim()), track, config);
183
- }
184
- break;
185
- } catch {}
186
- }
187
- }
188
-
189
- if (!plName || tracks.length === 0) {
190
- this.log(' 無法解析歌單');
191
- const emptyFile = path.join(this.playlistsPath, `${plName || 'unknown'}.m3u8`);
192
- if (fs.existsSync(emptyFile)) {
193
- fs.unlinkSync(emptyFile);
194
- this.log(' 已刪除空的播放清單檔案');
195
- }
196
- return null;
197
- }
198
-
199
- this.log(` 歌單: ${plName}, ${tracks.length} 首`);
200
- await this._buildIndex();
201
-
202
- const m3uPath = path.join(this.playlistsPath, `${plName}.m3u8`);
203
- fs.mkdirSync(this.playlistsPath, { recursive: true });
204
-
205
- // Clean up old M3U8 if playlist renamed
206
- const oldName = config.urlNames[url];
207
- if (oldName && oldName !== plName) {
208
- const oldFile = path.join(this.playlistsPath, `${oldName}.m3u8`);
209
- if (fs.existsSync(oldFile)) {
210
- fs.unlinkSync(oldFile);
211
- this.log(` 已清理舊歌單檔: ${oldName}.m3u8`);
212
- }
213
- }
214
-
215
- let sb = '#EXTM3U\n';
216
- let resolved = 0;
217
- for (const t of tracks) {
218
- const matched = this._findAudioFile(t);
219
- if (matched != null) {
220
- const localName = stemOf(matched);
221
- sb += `#EXTINF:-1,${localName}\n`;
222
- const absPath = path.resolve(matched);
223
- const absPl = path.resolve(path.dirname(m3uPath));
224
- // Echo Nightly compatible: URI-encode the path (keep slashes)
225
- const rel = encodeURIComponent(relativePath(absPath, absPl)).replace(/%2F/g, '/');
226
- sb += `${rel}\n`;
227
- resolved++;
228
- }
229
- }
230
- fs.writeFileSync(m3uPath, sb, 'utf-8');
231
- this.log(` 已儲存: ${plName}.m3u8 (已解析路徑: ${resolved}/${tracks.length})`);
232
-
233
- // Update config with the real playlist name
234
- config.urlNames[url] = plName;
235
- return plName;
236
- }
237
-
238
- _ensureCacheDir(config) {
239
- if (!config.basePath) return;
240
- const dir = path.join(config.basePath, 'spotify_cache');
241
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
242
- }
243
-
244
- _saveTrackCache(displayName, title, artists, track, config) {
245
- try {
246
- const album = track.album;
247
- let albumName, releaseDate, coverUrl;
248
- if (album) {
249
- albumName = album.name;
250
- releaseDate = album.release_date ?? album.date;
251
- const images = album.images;
252
- if (Array.isArray(images) && images.length) {
253
- coverUrl = images[0]?.url;
254
- }
255
- }
256
- const cleanName = sanitize(displayName);
257
- this._ensureCacheDir(config);
258
- const file = path.join(config.basePath, 'spotify_cache', `${cleanName}.json`);
259
- fs.writeFileSync(file, JSON.stringify({
260
- title, artist: artists,
261
- ...(albumName ? { album: albumName } : {}),
262
- ...(releaseDate ? { release_date: releaseDate } : {}),
263
- ...(coverUrl ? { cover_url: coverUrl } : {}),
264
- }), 'utf-8');
265
- } catch {}
266
- }
267
- }
@@ -1,42 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { readJson, writeJson } from './util.js';
4
-
5
- // Port of lib/services/snapshot_manager.dart
6
- export class SnapshotManager {
7
- static cachePath(config) {
8
- return path.join(config.basePath, 'snapshot_cache.json');
9
- }
10
-
11
- static loadCache(config) {
12
- const data = readJson(SnapshotManager.cachePath(config), null);
13
- if (data && data.playlists) return data;
14
- return { playlists: {}, version: '1.0' };
15
- }
16
-
17
- static saveCache(config, cache) {
18
- writeJson(SnapshotManager.cachePath(config), cache);
19
- }
20
-
21
- static detectRemovedSongs(config, playlistName, currentTracks) {
22
- const cache = SnapshotManager.loadCache(config);
23
- const playlists = cache.playlists || {};
24
- const old = playlists[playlistName];
25
- if (!old) return [];
26
- const oldTracks = old.tracks || [];
27
- const oldSet = new Set(oldTracks);
28
- const currentSet = new Set(currentTracks);
29
- return oldTracks.filter((t) => !currentSet.has(t));
30
- }
31
-
32
- static processPlaylist(config, playlistName, currentTracks) {
33
- const removed = SnapshotManager.detectRemovedSongs(config, playlistName, currentTracks);
34
- const cache = SnapshotManager.loadCache(config);
35
- cache.playlists[playlistName] = {
36
- tracks: currentTracks,
37
- last_updated: new Date().toISOString(),
38
- };
39
- SnapshotManager.saveCache(config, cache);
40
- return removed.length;
41
- }
42
- }