playlist-admin 2.6.76

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.
@@ -0,0 +1,490 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { LibraryIndex } from './library_index.js';
5
+ import { convert as convertAudio } from './audio_converter.js';
6
+ import { SpotifyScraper } from './scraper.js';
7
+ import { SnapshotManager } from './snapshot.js';
8
+ import { LufsService } from './lufs.js';
9
+ import { readMetadata } from './metadata.js';
10
+ import { PlaylistParser } from './playlist_parser.js';
11
+ import { saveConfig } from './config.js';
12
+ import { walkDir, stemOf, relativePath, isInternalPlaylist } from './util.js';
13
+
14
+ // Port of lib/pipeline/pipeline_orchestrator.dart
15
+ export class PipelineOrchestrator {
16
+ constructor({ config, onLog, onProgress, isCancelled = () => false }) {
17
+ this.config = config;
18
+ this.onLog = onLog;
19
+ this.onProgress = onProgress;
20
+ this.isCancelled = isCancelled;
21
+ this.totalRemoved = 0;
22
+ }
23
+
24
+ async run({ fromStep = 0, toStep } = {}) {
25
+ const steps = [
26
+ ['Convert M4A → MP3', 30.0],
27
+ ['Scrape Spotify playlists', 25.0],
28
+ ['Prune missing tracks', 15.0],
29
+ ['Organize unsorted songs', 10.0],
30
+ ['Enrich metadata', 10.0],
31
+ ['Measure LUFS', 10.0],
32
+ ];
33
+ const end = toStep ?? steps.length;
34
+ let doneWeight = 0;
35
+
36
+ for (let i = fromStep; i < end; i++) {
37
+ if (this.isCancelled()) break;
38
+ this.onLog(`--- Step ${i + 1}/${steps.length}: ${steps[i][0]} ---`);
39
+ this.onProgress(0, 100, i);
40
+ try {
41
+ await this._runStep(i, (pct) => {
42
+ const global = Math.round(doneWeight + (pct / 100.0) * steps[i][1]);
43
+ this.onProgress(global, 100, i);
44
+ });
45
+ } catch (e) {
46
+ this.onLog(` ❌ ${steps[i][0]} 失敗: ${e.message || e}`);
47
+ }
48
+ doneWeight += Math.trunc(steps[i][1]);
49
+ }
50
+ this.onLog(this.isCancelled() ? 'Pipeline 已取消' : 'Pipeline 完成');
51
+ }
52
+
53
+ async _runStep(index, progress) {
54
+ switch (index) {
55
+ case 0: await this._stepConvert(progress); break;
56
+ case 1: await this._stepScrape(progress); break;
57
+ case 2: await this._stepPrune(progress); break;
58
+ case 3: await this._stepUnsorted(progress); break;
59
+ case 4: await this._stepMetadata(progress); break;
60
+ case 5: await this._stepMeasureLufs(progress); break;
61
+ }
62
+ }
63
+
64
+ async _stepConvert(progress) {
65
+ const config = this.config;
66
+ const m4aDir = config.m4aPath;
67
+ const mp3Dir = config.mp3Path;
68
+ if (!fs.existsSync(m4aDir)) {
69
+ this.onLog(`M4A 資料夾不存在: ${m4aDir}`);
70
+ progress(100);
71
+ return;
72
+ }
73
+ fs.mkdirSync(mp3Dir, { recursive: true });
74
+
75
+ let m4aFiles = (await walkDir(m4aDir, { filter: (f) => f.toLowerCase().endsWith('.m4a') }));
76
+ if (m4aFiles.length === 0) {
77
+ const libM4a = await walkDir(config.libraryPath, { filter: (f) => f.toLowerCase().endsWith('.m4a') });
78
+ if (libM4a.length === 0) {
79
+ this.onLog('找不到任何 M4A 檔案');
80
+ progress(100);
81
+ return;
82
+ }
83
+ this.onLog(`在音樂庫根目錄找到 ${libM4a.length} 個 M4A 檔案`);
84
+ m4aFiles = libM4a;
85
+ } else {
86
+ this.onLog(`找到 ${m4aFiles.length} 個 M4A 檔案`);
87
+ }
88
+
89
+ const index = new LibraryIndex();
90
+ await index.build(config.libraryPath, this.onLog, { basePath: config.basePath });
91
+
92
+ const tasks = [];
93
+ let skipped = 0;
94
+ const totalM4a = m4aFiles.length;
95
+ let scanned = 0;
96
+
97
+ for (const m4a of m4aFiles) {
98
+ if (this.isCancelled()) return;
99
+
100
+ let existing = index.findMp3ForM4a(m4a, { useMtime: true });
101
+ if (existing) { skipped++; scanned++; this._scanLog(scanned, totalM4a, tasks, skipped); continue; }
102
+
103
+ const meta = await readMetadata(m4a, config);
104
+ existing = index.findMp3ForM4a(m4a, { useMtime: true, cachedMeta: meta });
105
+ if (existing) { skipped++; scanned++; this._scanLog(scanned, totalM4a, tasks, skipped); continue; }
106
+
107
+ const mp3Name = `${stemOf(m4a)}.mp3`;
108
+ const dest = path.join(config.mp3Path, mp3Name);
109
+ if (fs.existsSync(dest) && fs.statSync(dest).mtimeMs >= fs.statSync(m4a).mtimeMs) {
110
+ skipped++;
111
+ scanned++;
112
+ this._scanLog(scanned, totalM4a, tasks, skipped);
113
+ continue;
114
+ }
115
+
116
+ tasks.push({ src: m4a, dest, meta });
117
+ scanned++;
118
+ this._scanLog(scanned, totalM4a, tasks, skipped);
119
+ }
120
+
121
+ this.onLog(`待轉檔: ${tasks.length}, 跳過: ${skipped}`);
122
+ if (tasks.length === 0) { progress(100); return; }
123
+
124
+ const physicalCores = Math.max(2, Math.min(16, Math.trunc(os.cpus().length / 2)));
125
+ const concurrency = physicalCores;
126
+ let converted = 0;
127
+ let done = 0;
128
+ const lufs = new LufsService(this.config);
129
+
130
+ const worker = async () => {
131
+ while (true) {
132
+ if (this.isCancelled()) return;
133
+ const idx = next++;
134
+ if (idx >= tasks.length) return;
135
+ const t = tasks[idx];
136
+ const fname = path.basename(t.src);
137
+ const r = await convertAudio({
138
+ inputPath: t.src, outputPath: t.dest,
139
+ ffmpegPath: config.ffmpegPath || 'ffmpeg',
140
+ meta: t.meta, isCancelled: this.isCancelled,
141
+ });
142
+ if (this.isCancelled()) return;
143
+ done++;
144
+ if (r.ok) {
145
+ converted++;
146
+ lufs.cacheConversionLufsFast(t.src, r.lufs);
147
+ }
148
+ this.onLog(` [${done}/${tasks.length}] ${r.ok ? '✅' : '❌'} ${fname}`);
149
+ progress((done / tasks.length) * 100);
150
+ }
151
+ };
152
+ let next = 0;
153
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
154
+ this.onLog(`轉檔完成: ${converted} 個`);
155
+ }
156
+
157
+ _scanLog(scanned, total, tasks, skipped) {
158
+ if (scanned % 200 === 0 || scanned === total) {
159
+ this.onLog(` 比對: ${scanned}/${total} (待轉檔 ${tasks.length},跳過 ${skipped})`);
160
+ }
161
+ }
162
+
163
+ async _stepScrape(progress) {
164
+ const config = this.config;
165
+ const urls = Object.keys(config.urlNames);
166
+ if (urls.length === 0) {
167
+ this.onLog('沒有 Spotify URL');
168
+ progress(100);
169
+ return;
170
+ }
171
+ fs.mkdirSync(config.playlistsPath, { recursive: true });
172
+
173
+ const scraper = new SpotifyScraper({ log: this.onLog, playlistsPath: config.playlistsPath, libraryPath: config.libraryPath });
174
+ const plNames = await scraper.scrapeAll(urls, config);
175
+ saveConfig(config);
176
+
177
+ let totalRemoved = 0;
178
+ for (const plName of plNames) {
179
+ const plFile = path.join(config.playlistsPath, `${plName}.m3u8`);
180
+ if (!fs.existsSync(plFile)) continue;
181
+ try {
182
+ const tracks = PlaylistParser.parseTrackNames(plFile);
183
+ if (tracks.length === 0) continue;
184
+ const removed = SnapshotManager.processPlaylist(config, plName, tracks);
185
+ if (removed > 0) {
186
+ this.onLog(` 📋 ${plName}: 偵測到 ${removed} 首已移除歌曲`);
187
+ totalRemoved += removed;
188
+ }
189
+ } catch (e) {
190
+ this.onLog(` ⚠️ 無法處理 ${plName} 快照: ${e.message || e}`);
191
+ }
192
+ }
193
+ if (totalRemoved > 0) {
194
+ this.onLog(` 📋 共 ${totalRemoved} 首已移除歌曲被記錄`);
195
+ }
196
+ progress(100);
197
+ }
198
+
199
+ async _stepPrune(progress) {
200
+ const plDir = this.config.playlistsPath;
201
+ if (!fs.existsSync(plDir)) {
202
+ this.onLog('播放清單資料夾不存在');
203
+ progress(100);
204
+ return;
205
+ }
206
+ const files = fs.readdirSync(plDir)
207
+ .filter((f) => f.toLowerCase().endsWith('.m3u8') || f.toLowerCase().endsWith('.m3u'))
208
+ .map((f) => path.join(plDir, f));
209
+
210
+ this.totalRemoved = 0;
211
+ const totalFiles = files.length;
212
+
213
+ for (let i = 0; i < files.length; i++) {
214
+ if (this.isCancelled()) return;
215
+ const f = files[i];
216
+ const baseName = path.basename(f);
217
+ if (isInternalPlaylist(baseName)) {
218
+ this.onLog(` 跳過內部歌單: ${baseName}`);
219
+ progress(((i + 1) / totalFiles) * 100);
220
+ continue;
221
+ }
222
+ try {
223
+ const content = fs.readFileSync(f, 'utf-8').split('\n').map((l) => l.replace(/\r$/, ''));
224
+ const newLines = [];
225
+ let removed = 0;
226
+ const isM3u = content.some((l) => l.includes('#EXTM3U'));
227
+
228
+ let j = 0;
229
+ while (j < content.length) {
230
+ const line = content[j];
231
+ if (!line) { j++; continue; }
232
+ if (isM3u && line.startsWith('#EXTINF:')) {
233
+ let k = j + 1;
234
+ while (k < content.length && (!content[k].trim() || content[k].startsWith('#'))) k++;
235
+ if (k < content.length) {
236
+ const pathLine = content[k].trim();
237
+ if (this._trackFileExists(pathLine)) {
238
+ newLines.push(line, content[k]);
239
+ } else {
240
+ removed++;
241
+ }
242
+ j = k + 1;
243
+ } else {
244
+ newLines.push(line);
245
+ j++;
246
+ }
247
+ continue;
248
+ }
249
+ if (line.startsWith('#') || !line.trim()) {
250
+ newLines.push(line);
251
+ } else {
252
+ if (this._trackFileExists(line.trim())) newLines.push(line);
253
+ else removed++;
254
+ }
255
+ j++;
256
+ }
257
+
258
+ if (removed > 0) {
259
+ fs.writeFileSync(f, `${newLines.join('\n')}\n`, 'utf-8');
260
+ this.totalRemoved += removed;
261
+ this.onLog(` ${baseName}: 移除 ${removed} 首`);
262
+ }
263
+ } catch (e) {
264
+ this.onLog(` ⚠️ 無法處理 ${baseName}: ${e.message || e}`);
265
+ }
266
+ progress(((i + 1) / totalFiles) * 100);
267
+ }
268
+ this.onLog(`Prune 完成,共移除 ${this.totalRemoved} 首`);
269
+ }
270
+
271
+ _trackFileExists(entry) {
272
+ if (!entry.includes('.') && !entry.includes('\\')) return true;
273
+ const relToPl = path.join(this.config.playlistsPath, entry);
274
+ if (fs.existsSync(relToPl)) return true;
275
+ if (fs.existsSync(entry)) return true;
276
+ const fname = entry.replace(/[\\/]/g, '\\').split('\\').pop();
277
+ for (const base of [this.config.basePath, this.config.libraryPath, this.config.playlistsPath]) {
278
+ if (fs.existsSync(path.join(base, fname))) return true;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ async _stepUnsorted(progress) {
284
+ const plDir = this.config.playlistsPath;
285
+ if (!fs.existsSync(plDir)) { progress(100); return; }
286
+
287
+ const allPlaylistSongs = new Set();
288
+ for (const e of fs.readdirSync(plDir, { withFileTypes: true })) {
289
+ if (!e.isFile()) continue;
290
+ const low = e.name.toLowerCase();
291
+ if (!low.endsWith('.m3u8') && !low.endsWith('.m3u')) continue;
292
+ if (isInternalPlaylist(e.name)) continue;
293
+ try {
294
+ const names = PlaylistParser.parseTrackNames(path.join(plDir, e.name));
295
+ for (const n of names) {
296
+ allPlaylistSongs.add(n.replace(/\.[^.]+$/, '').toLowerCase());
297
+ }
298
+ } catch {}
299
+ }
300
+
301
+ const unsortedFiles = [];
302
+ const libDir = this.config.libraryPath;
303
+ if (fs.existsSync(libDir)) {
304
+ const files = await walkDir(libDir, { filter: (f) => /\.(mp3|m4a|flac)$/i.test(f) });
305
+ for (const f of files) {
306
+ const low = f.toLowerCase();
307
+ if (low.includes('podcast_downloads') || low.includes('podcast_rag')) continue;
308
+ const stem = stemOf(f).toLowerCase();
309
+ if (!allPlaylistSongs.has(stem)) unsortedFiles.push(f);
310
+ }
311
+ }
312
+
313
+ const unsortedPath = path.join(this.config.playlistsPath, '_Unsorted.m3u8');
314
+ const existingStems = new Set();
315
+ if (fs.existsSync(unsortedPath)) {
316
+ try {
317
+ const existing = PlaylistParser.parseTrackEntries(unsortedPath);
318
+ for (const e of existing) {
319
+ existingStems.add(stemOf(e).toLowerCase());
320
+ }
321
+ } catch {}
322
+ }
323
+
324
+ const newUnsorted = unsortedFiles.filter((f) => !existingStems.has(stemOf(f).toLowerCase()));
325
+ if (newUnsorted.length === 0) {
326
+ this.onLog(`未分類歌曲共 ${unsortedFiles.length} 首,無新增`);
327
+ progress(100);
328
+ return;
329
+ }
330
+
331
+ let sb = '';
332
+ if (existingStems.size === 0) sb += '#EXTM3U\n';
333
+ const absPlPath = path.dirname(unsortedPath);
334
+ for (const filePath of newUnsorted) {
335
+ const absFilePath = path.resolve(filePath);
336
+ sb += `#EXTINF:-1,${stemOf(filePath)}\n`;
337
+ sb += `${relativePath(absFilePath, absPlPath)}\n`;
338
+ }
339
+
340
+ fs.writeFileSync(unsortedPath, sb, { flag: existingStems.size === 0 ? 'w' : 'a', encoding: 'utf-8' });
341
+ this.onLog(`已為 ${newUnsorted.length} 首新未分類歌曲更新清單 (共 ${unsortedFiles.length} 首)`);
342
+ progress(100);
343
+ }
344
+
345
+ async _stepMetadata(progress) {
346
+ const config = this.config;
347
+ if (!config.enableMetadataEnrichment) {
348
+ this.onLog('Metadata enrichment 未啟用,跳過');
349
+ progress(100);
350
+ return;
351
+ }
352
+ this.onLog('Metadata enrichment 已啟用,開始掃描…');
353
+ // Scan files missing metadata, fill from spotify_cache / filename, apply via ffmpeg
354
+ const files = [];
355
+ for (const f of await walkDir(config.libraryPath, { filter: (x) => /\.(mp3|m4a|flac)$/i.test(x) })) {
356
+ const meta = await readMetadata(f, config);
357
+ if (!meta.title || !meta.artist) files.push(f);
358
+ }
359
+ if (files.length === 0) {
360
+ this.onLog('✅ 所有檔案都有完整 metadata');
361
+ } else {
362
+ this.onLog(`🎵 找到 ${files.length} 個檔案缺少 metadata,開始補充…`);
363
+ let success = 0;
364
+ const total = files.length;
365
+ for (let i = 0; i < files.length; i++) {
366
+ if (this.isCancelled()) return;
367
+ try {
368
+ const ok = await this._enrichFile(files[i], config);
369
+ if (ok) success++;
370
+ } catch (e) {
371
+ this.onLog(` ❌ ${path.basename(files[i])}: ${e.message || e}`);
372
+ }
373
+ if (onProgress) progress(((i + 1) / total) * 100);
374
+ if (i % 10 === 9 || i === files.length - 1) {
375
+ this.onLog(` 進度: ${i + 1}/${total} (成功: ${success})`);
376
+ }
377
+ }
378
+ this.onLog(`🎉 Metadata 補充完成: ${success}/${total}`);
379
+ }
380
+ progress(100);
381
+ }
382
+
383
+ async _enrichFile(filePath, config) {
384
+ const stem = stemOf(filePath);
385
+ const meta = await readMetadata(filePath, config);
386
+ let title = meta.title;
387
+ let artist = meta.artist;
388
+ let album;
389
+ let year;
390
+ let coverUrl;
391
+
392
+ const cacheDir = path.join(config.basePath, 'spotify_cache');
393
+ if (fs.existsSync(cacheDir)) {
394
+ const exact = path.join(cacheDir, `${stem}.json`);
395
+ let cached = null;
396
+ if (fs.existsSync(exact)) {
397
+ try { cached = JSON.parse(fs.readFileSync(exact, 'utf-8')); } catch {}
398
+ }
399
+ if (!cached) {
400
+ try {
401
+ for (const f of fs.readdirSync(cacheDir)) {
402
+ if (!f.endsWith('.json')) continue;
403
+ const data = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf-8'));
404
+ const cacheTitle = String(data.title || '').toLowerCase().replaceAll(' ', '');
405
+ const stemLower = stem.toLowerCase().replaceAll(' ', '');
406
+ if (cacheTitle.includes(stemLower) || stemLower.includes(cacheTitle)) { cached = data; break; }
407
+ }
408
+ } catch {}
409
+ }
410
+ if (cached) {
411
+ title = title || cached.title;
412
+ artist = artist || cached.artist;
413
+ album = album || cached.album;
414
+ const rd = cached.release_date;
415
+ if (rd && rd.length >= 4) year = rd.substring(0, 4);
416
+ coverUrl = coverUrl || cached.cover_url;
417
+ }
418
+ }
419
+
420
+ if ((!title || !title.length) && stem.includes(' - ')) {
421
+ const parts = stem.split(' - ');
422
+ if (parts.length >= 2) {
423
+ artist = parts[0].trim();
424
+ title = parts.slice(1).join(' - ').trim();
425
+ }
426
+ }
427
+ if (!title || !title.length) title = stem;
428
+
429
+ const ext = filePath.toLowerCase().split('.').pop();
430
+ const tmpPath = `${filePath}_tmp.${ext}`;
431
+ const cmd = ['ffmpeg', '-y', '-i', filePath, '-c', 'copy'];
432
+ if (title && title.length) cmd.push('-metadata', `title=${title}`);
433
+ if (artist && artist.length) cmd.push('-metadata', `artist=${artist}`);
434
+ if (album && album.length) cmd.push('-metadata', `album=${album}`);
435
+ if (year && year.length) cmd.push('-metadata', `date=${year}`);
436
+ if (coverUrl) {
437
+ // Best effort: fetch cover and embed as APIC when mpeg audio
438
+ try {
439
+ const resp = await fetch(coverUrl);
440
+ if (resp.ok && /^(mp3|m4a)$/i.test(ext)) {
441
+ const buf = Buffer.from(await resp.arrayBuffer());
442
+ const tmpCover = path.join(process.env.TEMP || '.', `pa_cover_${Date.now()}.jpg`);
443
+ fs.writeFileSync(tmpCover, buf);
444
+ cmd.push('-i', tmpCover, '-map', '0:a', '-map', '1:v', '-c:v', 'mjpeg', '-disposition:v', 'attached_pic');
445
+ const { execFile } = await import('node:child_process');
446
+ const { promisify } = await import('node:util');
447
+ await promisify(execFile)(cmd[0], cmd.slice(1), { windowsHide: true });
448
+ try { fs.unlinkSync(tmpCover); } catch {}
449
+ try {
450
+ fs.unlinkSync(filePath);
451
+ fs.renameSync(tmpPath, filePath);
452
+ } catch {}
453
+ this.onLog(` ✅ 已更新 metadata+封面: ${path.basename(filePath)}`);
454
+ return true;
455
+ }
456
+ } catch {}
457
+ }
458
+ cmd.push(tmpPath);
459
+ const { execFile } = await import('node:child_process');
460
+ const { promisify } = await import('node:util');
461
+ try {
462
+ const { stdout } = await promisify(execFile)(cmd[0], cmd.slice(1), { windowsHide: true, timeout: 120000 });
463
+ void stdout;
464
+ fs.unlinkSync(filePath);
465
+ fs.renameSync(tmpPath, filePath);
466
+ this.onLog(` ✅ 已更新 metadata: ${path.basename(filePath)}`);
467
+ return true;
468
+ } catch {
469
+ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch {}
470
+ this.onLog(` ⚠️ 寫入 metadata 失敗: ${path.basename(filePath)}`);
471
+ return false;
472
+ }
473
+ }
474
+
475
+ async _stepMeasureLufs(progress) {
476
+ try {
477
+ const svc = new LufsService(this.config);
478
+ await svc.measureAndNormalizePlaylistMp3s({
479
+ onLog: this.onLog,
480
+ onProgress: (done, total) => progress(total > 0 ? (done / total) * 100 : 0),
481
+ concurrency: 8,
482
+ tolerance: 2.0,
483
+ isCancelled: this.isCancelled,
484
+ });
485
+ } catch (e) {
486
+ this.onLog(`LUFS 異常: ${e.message || e}`);
487
+ }
488
+ progress(100);
489
+ }
490
+ }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import { stemOf } from './util.js';
3
+
4
+ // Port of lib/services/playlist_parser.dart
5
+ export class PlaylistParser {
6
+ static isInternalPlaylist(name) {
7
+ const n = name.toLowerCase();
8
+ if (n.includes('_removed songs')) return true;
9
+ return ['_unsorted', 'single tracks', '_unsorted_songs'].some((m) => n.includes(m));
10
+ }
11
+
12
+ static parseTrackNames(filePath) {
13
+ const songs = [];
14
+ if (!fs.existsSync(filePath)) return songs;
15
+ let content;
16
+ try { content = fs.readFileSync(filePath, 'utf-8'); }
17
+ catch { return songs; }
18
+
19
+ const lines = content.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
20
+ if (lines.length === 0) return songs;
21
+ const isM3u = lines.some((l) => l.includes('#EXTM3U'));
22
+
23
+ let i = 0;
24
+ while (i < lines.length) {
25
+ const line = lines[i];
26
+ if (isM3u && line.startsWith('#EXTINF:')) {
27
+ let j = i + 1;
28
+ while (j < lines.length && (lines[j].startsWith('#') || lines[j].length === 0)) j++;
29
+ if (j < lines.length) {
30
+ const pathLine = lines[j];
31
+ const name = pathLine.includes('\\') || pathLine.includes('/')
32
+ ? pathLine.replace(/[\\/]/g, '\\').split('\\').pop()
33
+ : pathLine;
34
+ songs.push(name.replace(/\.[^.]+$/, ''));
35
+ i = j + 1;
36
+ } else {
37
+ i++;
38
+ }
39
+ } else {
40
+ if (!line.startsWith('#')) {
41
+ songs.push(line);
42
+ }
43
+ i++;
44
+ }
45
+ }
46
+ return songs;
47
+ }
48
+
49
+ static parseTrackEntries(filePath) {
50
+ const entries = [];
51
+ if (!fs.existsSync(filePath)) return entries;
52
+ let content;
53
+ try { content = fs.readFileSync(filePath, 'utf-8'); }
54
+ catch { return entries; }
55
+
56
+ const lines = content.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
57
+ if (lines.length === 0) return entries;
58
+ const isM3u = lines.some((l) => l.includes('#EXTM3U'));
59
+
60
+ let i = 0;
61
+ while (i < lines.length) {
62
+ const line = lines[i];
63
+ if (isM3u && line.startsWith('#EXTINF:')) {
64
+ let j = i + 1;
65
+ while (j < lines.length && (lines[j].startsWith('#') || lines[j].length === 0)) j++;
66
+ if (j < lines.length) {
67
+ entries.push(lines[j]);
68
+ i = j + 1;
69
+ } else {
70
+ i++;
71
+ }
72
+ } else {
73
+ if (!line.startsWith('#')) {
74
+ entries.push(line);
75
+ }
76
+ i++;
77
+ }
78
+ }
79
+ return entries;
80
+ }
81
+ }