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