playlist-admin 2.6.81 → 2.6.83

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,47 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { run } from './util.js';
4
- import { readMetadata } from './metadata.js';
5
-
6
- // Port of lib/services/audio_converter.dart
7
- export async function convert({ inputPath, outputPath, format = 'mp3', ffmpegPath, meta = null, isCancelled }) {
8
- let ffmpeg = ffmpegPath || 'ffmpeg';
9
- if (ffmpeg.includes('\\') || ffmpeg.includes('/')) {
10
- if (!fs.existsSync(ffmpeg)) ffmpeg = 'ffmpeg';
11
- }
12
- if (!fs.existsSync(inputPath)) return { ok: false, lufs: null };
13
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
14
-
15
- if (inputPath.toLowerCase().endsWith(`.${format}`)) {
16
- fs.copyFileSync(inputPath, outputPath);
17
- return { ok: true, lufs: null };
18
- }
19
-
20
- if (!meta) meta = await readMetadata(inputPath, { ffmpegPath });
21
-
22
- const args = [
23
- '-y', '-i', inputPath,
24
- '-map_metadata', '0',
25
- '-id3v2_version', '3',
26
- '-threads', '0',
27
- ];
28
- if (format === 'mp3') {
29
- args.push('-af', 'loudnorm=I=-14:TP=-1:LRA=7', '-filter_threads', '0');
30
- }
31
- args.push('-codec:a', format === 'mp3' ? 'libmp3lame' : 'flac', '-q:a', '0', outputPath);
32
-
33
- try {
34
- const result = await run(ffmpeg, args, { timeout: 600000 });
35
- if (result.code !== 0) {
36
- // Remove the partial/empty output so the file isn't treated as done.
37
- try { fs.unlinkSync(outputPath); } catch {}
38
- return { ok: false, lufs: null };
39
- }
40
- const m = result.stderr.match(/Input Integrated:\s+([-\d.]+)/);
41
- const lufs = m ? parseFloat(m[1]) : null;
42
- return { ok: true, lufs };
43
- } catch {
44
- try { fs.unlinkSync(outputPath); } catch {}
45
- return { ok: false, lufs: null };
46
- }
47
- }
@@ -1,61 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import readline from 'node:readline';
4
- import { stemOf } from './util.js';
5
-
6
- // Port of _cleanupMp3 from lib/cli_main.dart
7
- export async function cleanupMp3(libPath) {
8
- const m4aDir = path.join(libPath, 'm4a');
9
- const mp3Dir = path.join(libPath, 'mp3');
10
- if (!fs.existsSync(m4aDir) || !fs.existsSync(mp3Dir)) {
11
- console.log('錯誤:找不到 mp3 或 m4a 目錄');
12
- return;
13
- }
14
-
15
- const m4aStems = new Set();
16
- for (const f of fs.readdirSync(m4aDir)) {
17
- if (f.toLowerCase().endsWith('.m4a')) {
18
- m4aStems.add(stemOf(f).toLowerCase());
19
- }
20
- }
21
- console.log(`M4A 檔案數: ${m4aStems.size}`);
22
-
23
- const orphans = [];
24
- for (const f of fs.readdirSync(mp3Dir)) {
25
- if (f.toLowerCase().endsWith('.mp3')) {
26
- const stem = stemOf(f).toLowerCase();
27
- if (!m4aStems.has(stem)) {
28
- orphans.push(path.join(mp3Dir, f));
29
- }
30
- }
31
- }
32
-
33
- if (orphans.length === 0) {
34
- console.log('✅ 沒有發現孤兒 MP3');
35
- return;
36
- }
37
-
38
- console.log(`\n⚠️ 找到 ${orphans.length} 個 metadata 更名版的孤兒 MP3:\n`);
39
- let totalSize = 0;
40
- for (const f of orphans) {
41
- const stat = fs.statSync(f);
42
- totalSize += stat.size;
43
- console.log(` ${path.basename(f)} (${(stat.size / 1024 / 1024).toFixed(1)} MB)`);
44
- }
45
- console.log(`\n 總計: ${(totalSize / 1024 / 1024).toFixed(1)} MB\n`);
46
-
47
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
48
- const answer = await new Promise((resolve) => rl.question('是否刪除?(y/N): ', resolve));
49
- rl.close();
50
- const input = (answer || '').trim().toLowerCase();
51
- if (input === 'y') {
52
- let deleted = 0;
53
- for (const f of orphans) {
54
- fs.unlinkSync(f);
55
- deleted++;
56
- }
57
- console.log(`✅ 已刪除 ${deleted} 個檔案`);
58
- } else {
59
- console.log('跳過刪除');
60
- }
61
- }
package/cli/lib/config.js DELETED
@@ -1,166 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
-
5
- export function appDataDir() {
6
- return path.join(
7
- process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
8
- 'Playlist Administrator',
9
- 'data',
10
- );
11
- }
12
-
13
- export function loadConfig() {
14
- // 1. Pointer file: LOCALAPPDATA\Playlist Administrator\data\config.json
15
- const pointerFile = path.join(appDataDir(), 'config.json');
16
- let basePath = '';
17
- if (fs.existsSync(pointerFile)) {
18
- try {
19
- const data = JSON.parse(fs.readFileSync(pointerFile, 'utf-8'));
20
- basePath = data.base_path || '';
21
- } catch {}
22
- }
23
-
24
- // 2. Main config: <base_path>\config.json
25
- const mainFile = basePath ? path.join(basePath, 'config.json') : '';
26
- const cfg = {};
27
- if (mainFile && fs.existsSync(mainFile)) {
28
- try {
29
- Object.assign(cfg, JSON.parse(fs.readFileSync(mainFile, 'utf-8')));
30
- } catch {}
31
- } else if (fs.existsSync(pointerFile)) {
32
- try {
33
- Object.assign(cfg, JSON.parse(fs.readFileSync(pointerFile, 'utf-8')));
34
- } catch {}
35
- }
36
-
37
- cfg.base_path = cfg.base_path || basePath;
38
-
39
- const config = {
40
- basePath: cfg.base_path || cfg.basePath || '',
41
- language: cfg.language || 'zh-TW',
42
- audioFormat: cfg.audio_format || cfg.audioFormat || 'mp3',
43
- maxThreads: cfg.max_threads || cfg.maxThreads || 4,
44
- debugMode: !!cfg.debug_mode,
45
- enableMetadataEnrichment: !!(cfg.enable_metadata_enrichment ?? cfg.enableMetadataEnrichment),
46
- spotubeExactMatch: cfg.spotube_exact_match !== false && cfg.spotubeExactMatch !== false,
47
- spotubeConvertMatchedOnly: !!(cfg.spotube_convert_matched_only ?? cfg.spotubeConvertMatchedOnly),
48
- ffmpegPath: cfg.ffmpeg_path || cfg.ffmpegPath || 'bin/ffmpeg.exe',
49
- spotubeExePath: cfg.spotube_exe_path || cfg.spotubeExePath || '',
50
- spotubeDownloadPath: cfg.spotube_download_path || cfg.spotubeDownloadPath || '',
51
- spotubeFolderName: cfg.spotube_folder_name || cfg.spotubeFolderName || 'spotube',
52
- lyricsFolderName: cfg.lyrics_folder_name || cfg.lyricsFolderName || 'Lyrics',
53
- autoUpdateCheck: cfg.auto_update_check !== false && cfg.autoUpdateCheck !== false,
54
- autoDownloadUpdate: !!(cfg.auto_download_update ?? cfg.autoDownloadUpdate),
55
- enableRetroactiveLyrics: !!(cfg.enable_retroactive_lyrics ?? cfg.enableRetroactiveLyrics),
56
- theme: cfg.theme || 'dark',
57
- skippedVersion: cfg.skipped_version || cfg.skippedVersion || '',
58
- setupCompleted: !!(cfg.setup_completed ?? cfg.setupCompleted),
59
- groqApiKey: cfg.groq_api_key || cfg.groqApiKey || '',
60
- groqConcurrency: cfg.groq_concurrency || cfg.groqConcurrency || 3,
61
- podcastSubscriptions: cfg.podcast_subscriptions || cfg.podcastSubscriptions || {},
62
- podcastHistory: cfg.podcast_history || cfg.podcastHistory || {},
63
- urlNames: cfg.url_names || cfg.urlNames || {},
64
- searchNames: cfg.search_names || cfg.searchNames || {},
65
- spotubeCoords: cfg.spotube_coords || cfg.spotubeCoords || {},
66
- lastUpdated: cfg.last_updated || cfg.lastUpdated || {},
67
- lyricsOffsets: cfg.lyrics_offsets || cfg.lyricsOffsets || {},
68
- };
69
-
70
- if (config.basePath) derivePaths(config);
71
- return config;
72
- }
73
-
74
- export function saveConfig(config) {
75
- if (!config.basePath) return;
76
- const path_ = path.join(config.basePath, 'config.json');
77
- const lock = `${path_}.lock`;
78
- // File lock to prevent concurrent writes (wait up to ~10s)
79
- for (let i = 0; i < 100 && fs.existsSync(lock); i++) {
80
- sleepSync(100);
81
- }
82
- if (fs.existsSync(lock)) {
83
- try { fs.unlinkSync(lock); } catch {}
84
- }
85
- try {
86
- fs.writeFileSync(lock, '');
87
- // Write in the Python/Dart-compatible snake_case schema. Writing JS
88
- // camelCase here would break the GUI/Flutter app (and this reader).
89
- const json = {
90
- base_path: config.basePath,
91
- language: config.language,
92
- audio_format: config.audioFormat,
93
- max_threads: config.maxThreads,
94
- debug_mode: config.debugMode,
95
- enable_metadata_enrichment: config.enableMetadataEnrichment,
96
- spotube_exact_match: config.spotubeExactMatch,
97
- spotube_convert_matched_only: config.spotubeConvertMatchedOnly,
98
- ffmpeg_path: config.ffmpegPath,
99
- spotube_exe_path: config.spotubeExePath,
100
- spotube_download_path: config.spotubeDownloadPath,
101
- spotube_folder_name: config.spotubeFolderName,
102
- lyrics_folder_name: config.lyricsFolderName,
103
- auto_update_check: config.autoUpdateCheck,
104
- auto_download_update: config.autoDownloadUpdate,
105
- enable_retroactive_lyrics: config.enableRetroactiveLyrics,
106
- theme: config.theme,
107
- skipped_version: config.skippedVersion,
108
- setup_completed: config.setupCompleted,
109
- groq_api_key: config.groqApiKey,
110
- groq_concurrency: config.groqConcurrency,
111
- podcast_subscriptions: config.podcastSubscriptions,
112
- podcast_history: config.podcastHistory,
113
- url_names: config.urlNames,
114
- search_names: config.searchNames,
115
- spotube_coords: config.spotubeCoords,
116
- last_updated: config.lastUpdated,
117
- lyrics_offsets: config.lyricsOffsets,
118
- spotify_urls: Object.keys(config.urlNames),
119
- };
120
- fs.writeFileSync(path_, JSON.stringify(json, null, 2), 'utf-8');
121
- } finally {
122
- try { fs.unlinkSync(lock); } catch {}
123
- }
124
- // Update pointer file
125
- const dir = appDataDir();
126
- fs.mkdirSync(dir, { recursive: true });
127
- fs.writeFileSync(
128
- path.join(dir, 'config.json'),
129
- JSON.stringify({ base_path: config.basePath, language: config.language }),
130
- 'utf-8',
131
- );
132
- }
133
-
134
- function sleepSync(ms) {
135
- const sw = Date.now();
136
- while (Date.now() - sw < ms) { /* spin */ }
137
- }
138
-
139
- function derivePaths(config) {
140
- const base = path.normalize(config.basePath);
141
- const music = path.join(base, 'Music');
142
- config.libraryPath = fs.existsSync(music) ? music : base;
143
- config.playlistsPath = path.join(base, 'Playlists');
144
- config.exportPath = path.join(base, 'USB_Output');
145
- config.lyricsPath = path.join(config.libraryPath, config.lyricsFolderName);
146
-
147
- const m4a = path.join(config.libraryPath, 'm4a');
148
- if (!fs.existsSync(m4a) && fs.existsSync(path.join(base, 'm4a'))) {
149
- config.m4aPath = path.join(base, 'm4a');
150
- } else {
151
- config.m4aPath = m4a;
152
- }
153
- const mp3 = path.join(config.libraryPath, 'mp3');
154
- if (!fs.existsSync(mp3) && fs.existsSync(path.join(base, 'mp3'))) {
155
- config.mp3Path = path.join(base, 'mp3');
156
- } else {
157
- config.mp3Path = mp3;
158
- }
159
-
160
- if (config.spotubeDownloadPath && config.spotubeDownloadPath.trim()) {
161
- config.resolvedSpotubeDownloadPath = config.spotubeDownloadPath;
162
- } else {
163
- const folder = config.spotubeFolderName || 'spotube';
164
- config.resolvedSpotubeDownloadPath = path.join(os.homedir(), 'Downloads', folder);
165
- }
166
- }
@@ -1,370 +0,0 @@
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
- }