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,370 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { walkDir, stemOf } from './util.js';
4
+ import { readMetadata } from './metadata.js';
5
+ import { toSimplified, isLoaded, loadDict } from './zhcn.js';
6
+
7
+ // Port of lib/services/library_index.dart
8
+ const artistAliases = {
9
+ 'claire kuo': '郭靜', 'jolin': '蔡依林', 'jolin tsai': '蔡依林',
10
+ 'crowd lu': '盧廣仲', 'pets tseng': '曾沛慈', 'evangeline wong': '王艷薇',
11
+ 'sabrina': '胡恂舞', 'sabrina hu': '胡恂舞', 'eric chou': '周興哲',
12
+ 'shi shi': '孫盛希', 'boon hui lu': '文慧如', 'vicky chen': '陳忻玥',
13
+ 'feng ze': '邱鋒澤', 'ivy': '艾薇', 'genblue': '幻藍小熊',
14
+ 'lbi': '利比', 'erin': '連穎', 'eleanor': '李芷婷',
15
+ 'ann bai': '白安', 'diana wang': '王詩安', 'ethan': '陳威全',
16
+ 'chih siou': '持修', 'show luo': '羅志祥',
17
+ };
18
+
19
+ const noisePatterns = [
20
+ '全新單曲', '單曲', '官方完整版', '官方', '完整版',
21
+ '高清', '動態歌詞版', '歌詞版', '官方版', '全新',
22
+ 'music video', 'official video', 'official music video',
23
+ 'video', 'loop', 'lyrics',
24
+ ];
25
+
26
+ export class LibraryIndex {
27
+ constructor() {
28
+ this._filenameIndex = new Map(); // tokenKey(string) -> [paths]
29
+ this._metadataIndex = new Map();
30
+ this._fileInfoMap = new Map(); // path -> {size, mtimeMs}
31
+ this._mp3Count = 0;
32
+ this._m4aCount = 0;
33
+ this._podcastMp3Count = 0;
34
+ this._podcastOtherCount = 0;
35
+ }
36
+
37
+ static _cacheDir() {
38
+ return path.join(
39
+ process.env.LOCALAPPDATA || '',
40
+ 'Playlist Administrator', 'data');
41
+ }
42
+ static _fingerprintFile() {
43
+ return path.join(LibraryIndex._cacheDir(), 'index_fingerprint.json');
44
+ }
45
+ static _metaIndexFile() {
46
+ return path.join(LibraryIndex._cacheDir(), 'index_metadata.json');
47
+ }
48
+
49
+ static _buildFingerprint(files) {
50
+ const fp = new Set();
51
+ for (const f of files) {
52
+ try {
53
+ const st = fs.statSync(f);
54
+ fp.add(`${f}|${st.mtimeMs}`);
55
+ } catch {
56
+ fp.add(`${f}|0`);
57
+ }
58
+ }
59
+ return fp;
60
+ }
61
+
62
+ async build(libraryPath, log, { basePath } = {}) {
63
+ loadDict();
64
+ log('掃描音檔…');
65
+ const allFiles = await this._walkDir(libraryPath);
66
+ if (basePath && basePath !== libraryPath) {
67
+ const baseFiles = await this._walkDir(basePath);
68
+ for (const f of baseFiles) {
69
+ if (!allFiles.includes(f)) allFiles.push(f);
70
+ }
71
+ }
72
+ const mp3s = [];
73
+ const m4as = [];
74
+ for (const f of allFiles) {
75
+ const low = f.toLowerCase();
76
+ if (low.endsWith('.mp3')) mp3s.push(f);
77
+ else if (low.endsWith('.m4a')) m4as.push(f);
78
+ }
79
+ this._mp3Count = mp3s.length;
80
+ this._m4aCount = m4as.length;
81
+ this._fileInfoMap = new Map();
82
+ for (const f of allFiles) {
83
+ try {
84
+ const st = fs.statSync(f);
85
+ this._fileInfoMap.set(f, { size: st.size, mtimeMs: st.mtimeMs });
86
+ } catch {}
87
+ }
88
+ this._filenameIndex = this._buildFilenameIndex(mp3s);
89
+
90
+ let cachedFingerprint = null;
91
+ try {
92
+ const f = LibraryIndex._fingerprintFile();
93
+ if (fs.existsSync(f)) {
94
+ cachedFingerprint = new Set(JSON.parse(fs.readFileSync(f, 'utf-8')).fingerprint || []);
95
+ }
96
+ } catch {}
97
+
98
+ const currentFp = LibraryIndex._buildFingerprint(allFiles);
99
+ const changedStems = new Set();
100
+ if (cachedFingerprint) {
101
+ for (const entry of currentFp) {
102
+ if (!cachedFingerprint.has(entry)) {
103
+ const pipeIdx = entry.indexOf('|');
104
+ if (pipeIdx > 0) changedStems.add(stemOf(entry.substring(0, pipeIdx)).toLowerCase());
105
+ }
106
+ }
107
+ for (const entry of cachedFingerprint) {
108
+ if (!currentFp.has(entry)) {
109
+ const pipeIdx = entry.indexOf('|');
110
+ if (pipeIdx > 0) changedStems.add(stemOf(entry.substring(0, pipeIdx)).toLowerCase());
111
+ }
112
+ }
113
+ }
114
+
115
+ this._metadataIndex = new Map();
116
+
117
+ if (changedStems.size === 0 && cachedFingerprint) {
118
+ log(`MP3: ${this._mp3Count}, M4A: ${this._m4aCount} (無變動,載入快取索引)`);
119
+ const cached = this._loadMetaIndex();
120
+ if (cached) {
121
+ for (const [keyJson, paths] of Object.entries(cached)) {
122
+ try {
123
+ const tokens = JSON.parse(keyJson);
124
+ this._metadataIndex.set(tokens.join('\u0000'), paths);
125
+ } catch {}
126
+ }
127
+ log(` metadata 索引載入完成: ${this._metadataIndex.size} 首`);
128
+ this._saveFingerprint(currentFp);
129
+ return;
130
+ }
131
+ }
132
+
133
+ log(`MP3: ${this._mp3Count}, M4A: ${this._m4aCount}`);
134
+ log('建立檔名索引…');
135
+ if (changedStems.size > 0) {
136
+ log(`metadata 新增/變更: ${changedStems.size} 個檔案`);
137
+ }
138
+
139
+ // Load cached metadata for unchanged files
140
+ const cached = this._loadMetaIndex();
141
+ const cachedPaths = new Set();
142
+ if (cached) {
143
+ for (const [keyJson, paths] of Object.entries(cached)) {
144
+ let skip = false;
145
+ for (const p of paths) {
146
+ if (changedStems.has(stemOf(p).toLowerCase())) { skip = true; break; }
147
+ }
148
+ if (!skip) {
149
+ this._metadataIndex.set(keyJson, paths);
150
+ for (const p of paths) cachedPaths.add(p);
151
+ }
152
+ }
153
+ }
154
+
155
+ const toIndex = mp3s.filter((f) => {
156
+ if (changedStems.size === 0) return true;
157
+ return changedStems.has(stemOf(f).toLowerCase());
158
+ });
159
+
160
+ log(`讀取 metadata 索引 (${toIndex.length}/${mp3s.length} 個檔案)…`);
161
+ const newEntries = await this._buildMetadataIndex(toIndex, log);
162
+ for (const [k, v] of newEntries) this._metadataIndex.set(k, v);
163
+
164
+ // Remove entries for deleted files
165
+ const onDisk = new Set(mp3s);
166
+ for (const [k, paths] of [...this._metadataIndex]) {
167
+ const kept = paths.filter((p) => onDisk.has(p));
168
+ if (kept.length === 0) this._metadataIndex.delete(k);
169
+ else this._metadataIndex.set(k, kept);
170
+ }
171
+
172
+ if (changedStems.size > 0) {
173
+ log(`索引完成 (新增 ${newEntries.length},快取 ${this._metadataIndex.size - newEntries.length})`);
174
+ } else {
175
+ log('索引完成');
176
+ }
177
+
178
+ const serializable = {};
179
+ for (const [k, v] of this._metadataIndex) serializable[k] = v;
180
+ this._saveMetaIndex(serializable);
181
+ this._saveFingerprint(currentFp);
182
+ }
183
+
184
+ _saveFingerprint(fp) {
185
+ try {
186
+ const dir = LibraryIndex._cacheDir();
187
+ fs.mkdirSync(dir, { recursive: true });
188
+ fs.writeFileSync(LibraryIndex._fingerprintFile(), JSON.stringify({ fingerprint: [...fp] }));
189
+ } catch {}
190
+ }
191
+
192
+ _loadMetaIndex() {
193
+ try {
194
+ const f = LibraryIndex._metaIndexFile();
195
+ if (!fs.existsSync(f)) return null;
196
+ return JSON.parse(fs.readFileSync(f, 'utf-8'));
197
+ } catch { return null; }
198
+ }
199
+
200
+ _saveMetaIndex(index) {
201
+ try {
202
+ const dir = LibraryIndex._cacheDir();
203
+ fs.mkdirSync(dir, { recursive: true });
204
+ fs.writeFileSync(LibraryIndex._metaIndexFile(), JSON.stringify(index));
205
+ } catch {}
206
+ }
207
+
208
+ async _walkDir(dir) {
209
+ const result = [];
210
+ if (!fs.existsSync(dir)) return result;
211
+ const stack = [dir];
212
+ while (stack.length) {
213
+ const cur = stack.pop();
214
+ let entries;
215
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
216
+ for (const e of entries) {
217
+ const full = path.join(cur, e.name);
218
+ if (e.isDirectory()) stack.push(full);
219
+ else if (e.isFile()) {
220
+ const low = full.toLowerCase();
221
+ if (!low.endsWith('.mp3') && !low.endsWith('.m4a') && !low.endsWith('.flac')) continue;
222
+ if (low.includes('podcast_downloads') || low.includes('podcast_rag')) {
223
+ if (low.endsWith('.mp3')) this._podcastMp3Count++;
224
+ else this._podcastOtherCount++;
225
+ continue;
226
+ }
227
+ result.push(full);
228
+ }
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+
234
+ _buildFilenameIndex(files) {
235
+ const index = new Map();
236
+ for (const f of files) {
237
+ const tokens = this._normalize(stemOf(f));
238
+ if (tokens.length) {
239
+ const key = tokens.join('\u0000');
240
+ if (!index.has(key)) index.set(key, []);
241
+ index.get(key).push(f);
242
+ }
243
+ }
244
+ return index;
245
+ }
246
+
247
+ async _buildMetadataIndex(files, log) {
248
+ const index = new Map();
249
+ const batchSize = 20;
250
+ for (let i = 0; i < files.length; i += batchSize) {
251
+ const batch = files.slice(i, i + batchSize);
252
+ const metas = await Promise.all(batch.map((f) => readMetadata(f, this._config)));
253
+ for (let j = 0; j < batch.length; j++) {
254
+ const meta = metas[j];
255
+ if (meta.title) {
256
+ const tokens = this._normalize(meta.title);
257
+ if (tokens.length) {
258
+ const key = tokens.join('\u0000');
259
+ if (!index.has(key)) index.set(key, []);
260
+ index.get(key).push(batch[j]);
261
+ }
262
+ }
263
+ }
264
+ if ((i + batch.length) % 500 === 0 || i + batch.length >= files.length) {
265
+ log(` metadata 索引: ${Math.min(i + batch.length, files.length)}/${files.length}`);
266
+ }
267
+ }
268
+ return index;
269
+ }
270
+
271
+ _normalize(text) {
272
+ let t = (text || '').toLowerCase().trim();
273
+ if (!t) return [];
274
+
275
+ // Remove spaces between CJK chars
276
+ t = t.replace(/([\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff])\s+(?=[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff])/g, '$1');
277
+
278
+ if (isLoaded()) t = toSimplified(t);
279
+
280
+ for (const [alias, value] of Object.entries(artistAliases)) {
281
+ const re = new RegExp(`\\b${alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
282
+ t = t.replace(re, isLoaded() ? toSimplified(value) : value.toLowerCase());
283
+ }
284
+
285
+ // Remove "E " prefix artifact
286
+ t = t.replace(/(^|(?<=[^a-z0-9]))e(?=[a-z\u4e00-\u9fff\u3040-\u30ff])/g, '');
287
+
288
+ for (const p of noisePatterns) {
289
+ t = t.replace(new RegExp(p, 'gi'), ' ');
290
+ }
291
+
292
+ t = t.replace(/\s*(feat|ft|vs)\.?\s*|\s*[&,x]\s*/gi, ' ');
293
+
294
+ t = t.replace(/[\(\[【([][^\)\]】)]]*(?:live|remix|mv|official|lyrics?\s*video|music\s*video)[^\)\]】)]]*[\)\]】)]]/gi, ' ');
295
+
296
+ t = t.replace(/([a-z])([\u4e00-\u9fff])/g, '$1 $2');
297
+ t = t.replace(/([\u4e00-\u9fff])([a-z])/g, '$1 $2');
298
+
299
+ const parts = t.split(/[\s\-_()\[\]【】,./::,。、!?()「」""''《》〈〉\u3000]+/);
300
+ return parts.filter((p) => p.length > 0);
301
+ }
302
+
303
+ findMp3ForM4a(m4aPath, { useMtime = true, cachedMeta = null } = {}) {
304
+ const stem = stemOf(m4aPath);
305
+ const tokens = this._normalize(stem);
306
+ const m4aInfo = this._fileInfoMap.get(m4aPath);
307
+
308
+ const mtimeOk = (mp3Path) => {
309
+ if (!useMtime) return true;
310
+ const mp3Info = this._fileInfoMap.get(mp3Path);
311
+ return mp3Info && m4aInfo && mp3Info.mtimeMs >= m4aInfo.mtimeMs;
312
+ };
313
+
314
+ // 1. exact filename match
315
+ const exact = this._findInIndex(tokens, this._filenameIndex);
316
+ if (exact && mtimeOk(exact)) return exact;
317
+
318
+ // 2. fuzzy filename match
319
+ for (const [key, paths] of this._filenameIndex) {
320
+ const entryTokens = key.split('\u0000');
321
+ if (this._isSubset(tokens, entryTokens) || this._isSubset(entryTokens, tokens)) {
322
+ for (const f of paths) {
323
+ if (f.toLowerCase().endsWith('.mp3') && this._fileInfoMap.has(f) && mtimeOk(f)) return f;
324
+ }
325
+ }
326
+ }
327
+
328
+ // 3. metadata-based matching
329
+ if (cachedMeta && cachedMeta.title) {
330
+ const titleTokens = this._normalize(cachedMeta.title);
331
+ for (const [key, paths] of this._metadataIndex) {
332
+ const entryTokens = key.split('\u0000');
333
+ if (this._isSubset(titleTokens, entryTokens) || this._isSubset(entryTokens, titleTokens)) {
334
+ for (const f of paths) {
335
+ if (f.toLowerCase().endsWith('.mp3') && this._fileInfoMap.has(f)) return f;
336
+ }
337
+ }
338
+ }
339
+ if (cachedMeta.artist) {
340
+ const combined = this._normalize(`${cachedMeta.title} - ${cachedMeta.artist}`);
341
+ for (const [key, paths] of this._filenameIndex) {
342
+ const entryTokens = key.split('\u0000');
343
+ if (this._isSubset(combined, entryTokens) || this._isSubset(entryTokens, combined)) {
344
+ for (const f of paths) {
345
+ if (f.toLowerCase().endsWith('.mp3') && this._fileInfoMap.has(f)) return f;
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+
352
+ return null;
353
+ }
354
+
355
+ _findInIndex(tokens, index) {
356
+ const key = tokens.join('\u0000');
357
+ const paths = index.get(key);
358
+ if (paths) {
359
+ for (const f of paths) {
360
+ if (f.toLowerCase().endsWith('.mp3') && this._fileInfoMap.has(f)) return f;
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ _isSubset(small, big) {
367
+ if (small.length > big.length) return false;
368
+ return small.every((s) => big.some((b) => b.includes(s)));
369
+ }
370
+ }
@@ -0,0 +1,172 @@
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)) out[k] = typeof v === 'number' ? v : parseFloat(v);
19
+ return out;
20
+ }
21
+
22
+ _save(fmt, cache) {
23
+ writeJson(this._cacheFile(fmt), cache);
24
+ }
25
+
26
+ _resolvePlaylistPath(entry, playlistsPath, basePath, libraryPath) {
27
+ if (!entry) return null;
28
+ if (!entry.includes('.') && !entry.includes('\\') && !entry.includes('/')) return null;
29
+ const resolved = path.join(playlistsPath, entry);
30
+ if (fs.existsSync(resolved)) return resolved;
31
+ if (fs.existsSync(entry)) return entry;
32
+ const fname = entry.replace(/[\\/]/g, '\\').split('\\').pop();
33
+ for (const base of [basePath, libraryPath, playlistsPath]) {
34
+ const cand = path.join(base, fname);
35
+ if (fs.existsSync(cand)) return cand;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ getPlaylistMp3Files() {
41
+ const { playlistsPath, basePath, libraryPath } = this.config;
42
+ if (!fs.existsSync(playlistsPath)) return new Set();
43
+ const mp3Files = new Set();
44
+ for (const e of fs.readdirSync(playlistsPath, { withFileTypes: true })) {
45
+ if (!e.isFile()) continue;
46
+ const low = e.name.toLowerCase();
47
+ if (!low.endsWith('.m3u8') && !low.endsWith('.m3u')) continue;
48
+ if (isInternalPlaylist(e.name)) continue;
49
+ try {
50
+ const content = fs.readFileSync(path.join(playlistsPath, e.name), 'utf-8');
51
+ for (const line of content.split('\n')) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith('#')) continue;
54
+ if (!trimmed.includes('.mp3') && !trimmed.includes('.m4a') && !trimmed.includes('.flac')) continue;
55
+ const resolved = this._resolvePlaylistPath(trimmed, playlistsPath, basePath, libraryPath);
56
+ if (resolved && resolved.toLowerCase().endsWith('.mp3')) {
57
+ mp3Files.add(resolved);
58
+ }
59
+ }
60
+ } catch {}
61
+ }
62
+ return mp3Files;
63
+ }
64
+
65
+ async measureAndNormalizePlaylistMp3s({ onLog, onProgress, concurrency = 8, tolerance = 2.0, isCancelled }) {
66
+ const { libraryPath, playlistsPath, basePath } = this.config;
67
+ if (!libraryPath || !playlistsPath) {
68
+ onLog('Library or Playlists path not configured');
69
+ return;
70
+ }
71
+ onLog('掃描歌單中的 MP3 檔案…');
72
+ const playlistFiles = this.getPlaylistMp3Files();
73
+ if (playlistFiles.size === 0) {
74
+ onLog('歌單中沒有 MP3 檔案');
75
+ return;
76
+ }
77
+ onLog(`歌單內共 ${playlistFiles.size} 個 MP3`);
78
+
79
+ const cache = this._load('mp3');
80
+ const target = -14.0;
81
+ const needsNormalize = [];
82
+ let skipCount = 0;
83
+ for (const p of playlistFiles) {
84
+ const cached = cache[p];
85
+ if (cached != null && Math.abs(cached - target) <= tolerance) skipCount++;
86
+ else needsNormalize.push(p);
87
+ }
88
+ if (needsNormalize.length === 0) {
89
+ onLog(`所有歌單 MP3 已在 ${target}±${tolerance}LUFS 範圍內 (已快取 ${skipCount} 檔)`);
90
+ return;
91
+ }
92
+ onLog(`需 Normalize/測量: ${needsNormalize.length} 檔 (${skipCount} 檔已合格)`);
93
+ const ffmpeg = resolveFfmpeg(this.config);
94
+ const total = needsNormalize.length;
95
+ let done = 0;
96
+ let lastSave = 0;
97
+
98
+ for (let i = 0; i < total; i += concurrency) {
99
+ if (isCancelled && isCancelled()) break;
100
+ const batch = needsNormalize.slice(i, i + concurrency);
101
+ await Promise.all(batch.map((p) => this._normalizeOne(p, ffmpeg, target, cache, onLog, isCancelled)));
102
+ if (isCancelled && isCancelled()) break;
103
+ done += batch.length;
104
+ if (onProgress) onProgress(done, total);
105
+ if (done - lastSave >= 10) {
106
+ this._save('mp3', cache);
107
+ lastSave = done;
108
+ onLog(` 進度: ${done}/${total}`);
109
+ }
110
+ }
111
+ this._save('mp3', cache);
112
+ if (isCancelled && isCancelled()) {
113
+ onLog(`LUFS 已取消(已處理 ${done} 檔)`);
114
+ } else {
115
+ onLog(`完成: ${done} 個檔案已統一至 ${target} LUFS`);
116
+ }
117
+ }
118
+
119
+ async _normalizeOne(absPath, ffmpeg, target, cache, onLog, isCancelled) {
120
+ if (!fs.existsSync(absPath)) return;
121
+ const base = absPath.substring(0, absPath.lastIndexOf('.'));
122
+ const tmp = `${base}_tmp.mp3`;
123
+ const name = absPath.split('\\').pop();
124
+ try {
125
+ const result = await run(ffmpeg, [
126
+ '-y', '-i', absPath,
127
+ '-af', `loudnorm=I=${target}:TP=-1:LRA=7`,
128
+ '-c:a', 'libmp3lame', '-q:a', '2', tmp,
129
+ ], { timeout: 600000 });
130
+ if (result.code !== 0 || !fs.existsSync(tmp)) {
131
+ onLog(` ❌ ${name} normalize 失敗`);
132
+ return;
133
+ }
134
+ let inputLufs = target;
135
+ const m = result.stderr.match(/Input Integrated:\s+([-\d.]+)/);
136
+ if (m) inputLufs = parseFloat(m[1]);
137
+ fs.renameSync(tmp, absPath);
138
+ cache[absPath] = target;
139
+ if (Math.abs(inputLufs - target) > 0.1) {
140
+ onLog(` ✅ ${name} (${inputLufs.toFixed(1)} → ${target})`);
141
+ }
142
+ } catch (ex) {
143
+ onLog(` ⚠️ ${name}: ${ex.message || ex}`);
144
+ }
145
+ }
146
+
147
+ cacheConversionLufsFast(m4aPath, lufs) {
148
+ const m4aCache = this._load('m4a');
149
+ if (!(m4aPath in m4aCache) && lufs != null) {
150
+ m4aCache[m4aPath] = lufs;
151
+ this._save('m4a', m4aCache);
152
+ }
153
+ }
154
+
155
+ measureOne(path_, onResult, isCancelled) {
156
+ const ffmpeg = resolveFfmpeg(this.config);
157
+ return run(ffmpeg, ['-i', path_, '-af', 'loudnorm=print_format=json', '-f', 'null', '-', '-hide_banner', '-y'], { timeout: 300000 })
158
+ .then((result) => {
159
+ if (result.code !== 0) { onResult(-14.0); return; }
160
+ const stderr = result.stderr;
161
+ const jsonStart = stderr.lastIndexOf('{');
162
+ if (jsonStart >= 0) {
163
+ try {
164
+ const data = JSON.parse(stderr.substring(jsonStart));
165
+ if (data.input_i != null) { onResult(parseFloat(data.input_i)); return; }
166
+ } catch {}
167
+ }
168
+ onResult(-14.0);
169
+ })
170
+ .catch(() => onResult(-14.0));
171
+ }
172
+ }
@@ -0,0 +1,31 @@
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
+ }