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.
package/cli/index.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig } from './lib/config.js';
3
+ import { PipelineOrchestrator } from './lib/pipeline.js';
4
+ import { PodcastPipeline } from './lib/podcast.js';
5
+ import { SpotubeController } from './lib/spotube.js';
6
+ import { cleanupMp3 } from './lib/cleanup.js';
7
+
8
+ const HELP = `Playlist Administrator CLI (Node)
9
+
10
+ Usage:
11
+ playlist-admin pipeline Run full pipeline
12
+ playlist-admin pipeline --step N Run single step
13
+ playlist-admin spotube-download <name> Download one playlist
14
+ playlist-admin spotube-download-all Download all playlists
15
+ playlist-admin spotube-move Move M4A files
16
+ playlist-admin spotube-cleanup Remove metadata-renamed mp3 duplicates
17
+ playlist-admin podcast Run podcast pipeline
18
+ playlist-admin status Show status
19
+ `;
20
+
21
+ function getFlag(args, flag) {
22
+ const idx = args.indexOf(flag);
23
+ if (idx >= 0 && idx + 1 < args.length) {
24
+ return parseInt(args[idx + 1], 10) || 0;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ const onProgress = () => {};
30
+ const isCancelled = () => false;
31
+
32
+ async function main() {
33
+ const args = process.argv.slice(2);
34
+ if (args.length === 0) {
35
+ console.log(HELP);
36
+ return;
37
+ }
38
+
39
+ const config = loadConfig();
40
+ const cmd = args[0];
41
+
42
+ switch (cmd) {
43
+ case 'pipeline': {
44
+ const fromStep = getFlag(args, '--step');
45
+ const orch = new PipelineOrchestrator({ config, onLog: (m) => console.log(m), onProgress, isCancelled });
46
+ await orch.run({ fromStep });
47
+ break;
48
+ }
49
+
50
+ case 'spotube-download': {
51
+ if (args.length < 2) {
52
+ console.log('Usage: playlist-admin spotube-download <playlist_name>');
53
+ return;
54
+ }
55
+ const ctrl = new SpotubeController({
56
+ libraryPath: config.libraryPath,
57
+ coords: config.spotubeCoords,
58
+ });
59
+ if (!(await ctrl.isRunning())) {
60
+ console.log('Spotube is not running');
61
+ return;
62
+ }
63
+ await ctrl.downloadPlaylist(args[1]);
64
+ break;
65
+ }
66
+
67
+ case 'spotube-download-all': {
68
+ const ctrl = new SpotubeController({
69
+ libraryPath: config.libraryPath,
70
+ coords: config.spotubeCoords,
71
+ });
72
+ if (!(await ctrl.isRunning())) {
73
+ console.log('Spotube is not running');
74
+ return;
75
+ }
76
+ const names = Object.values(config.urlNames);
77
+ for (const name of names) {
78
+ console.log(`Downloading: ${name}`);
79
+ await ctrl.downloadPlaylist(name);
80
+ }
81
+ break;
82
+ }
83
+
84
+ case 'spotube-move': {
85
+ const ctrl = new SpotubeController({ libraryPath: config.libraryPath });
86
+ const moved = await ctrl.moveDownloads(config.resolvedSpotubeDownloadPath);
87
+ console.log(`Moved ${moved} files`);
88
+ break;
89
+ }
90
+
91
+ case 'spotube-cleanup': {
92
+ await cleanupMp3(config.libraryPath);
93
+ break;
94
+ }
95
+
96
+ case 'podcast': {
97
+ const pipeline = new PodcastPipeline({ config, onLog: (m) => console.log(m), isCancelled });
98
+ await pipeline.run();
99
+ break;
100
+ }
101
+
102
+ case 'status': {
103
+ console.log(`Library: ${config.libraryPath}`);
104
+ console.log(`Playlists: ${Object.keys(config.urlNames).length}`);
105
+ console.log(`Downloaded: ${Object.keys(config.lastUpdated || {}).length}`);
106
+ break;
107
+ }
108
+
109
+ default:
110
+ console.log(`Unknown command: ${cmd}\n`);
111
+ console.log(HELP);
112
+ }
113
+ }
114
+
115
+ main().catch((e) => {
116
+ console.error(e);
117
+ process.exit(1);
118
+ });
@@ -0,0 +1,42 @@
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) return { ok: false, lufs: null };
36
+ const m = result.stderr.match(/Input Integrated:\s+([-\d.]+)/);
37
+ const lufs = m ? parseFloat(m[1]) : null;
38
+ return { ok: true, lufs };
39
+ } catch {
40
+ return { ok: false, lufs: null };
41
+ }
42
+ }
@@ -0,0 +1,61 @@
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
+ }
@@ -0,0 +1,138 @@
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 || '',
41
+ language: cfg.language || 'zh-TW',
42
+ audioFormat: cfg.audio_format || 'mp3',
43
+ maxThreads: cfg.max_threads || 4,
44
+ debugMode: !!cfg.debug_mode,
45
+ enableMetadataEnrichment: !!cfg.enable_metadata_enrichment,
46
+ spotubeExactMatch: cfg.spotube_exact_match !== false,
47
+ spotubeConvertMatchedOnly: !!cfg.spotube_convert_matched_only,
48
+ ffmpegPath: cfg.ffmpeg_path || 'bin/ffmpeg.exe',
49
+ spotubeExePath: cfg.spotube_exe_path || '',
50
+ spotubeDownloadPath: cfg.spotube_download_path || '',
51
+ spotubeFolderName: cfg.spotube_folder_name || 'spotube',
52
+ lyricsFolderName: cfg.lyrics_folder_name || 'Lyrics',
53
+ autoUpdateCheck: cfg.auto_update_check !== false,
54
+ autoDownloadUpdate: !!cfg.auto_download_update,
55
+ enableRetroactiveLyrics: !!cfg.enable_retroactive_lyrics,
56
+ theme: cfg.theme || 'dark',
57
+ skippedVersion: cfg.skipped_version || '',
58
+ setupCompleted: !!cfg.setup_completed,
59
+ groqApiKey: cfg.groq_api_key || '',
60
+ groqConcurrency: cfg.groq_concurrency || 3,
61
+ podcastSubscriptions: cfg.podcast_subscriptions || {},
62
+ podcastHistory: cfg.podcast_history || {},
63
+ urlNames: cfg.url_names || {},
64
+ searchNames: cfg.search_names || {},
65
+ spotubeCoords: cfg.spotube_coords || {},
66
+ lastUpdated: cfg.last_updated || {},
67
+ lyricsOffsets: cfg.lyrics_offsets || {},
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
+ const json = {};
88
+ for (const [k, v] of Object.entries(config)) {
89
+ json[k] = v;
90
+ }
91
+ json.spotify_urls = Object.keys(config.urlNames);
92
+ fs.writeFileSync(path_, JSON.stringify(json, null, 2), 'utf-8');
93
+ } finally {
94
+ try { fs.unlinkSync(lock); } catch {}
95
+ }
96
+ // Update pointer file
97
+ const dir = appDataDir();
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ fs.writeFileSync(
100
+ path.join(dir, 'config.json'),
101
+ JSON.stringify({ base_path: config.basePath, language: config.language }),
102
+ 'utf-8',
103
+ );
104
+ }
105
+
106
+ function sleepSync(ms) {
107
+ const sw = Date.now();
108
+ while (Date.now() - sw < ms) { /* spin */ }
109
+ }
110
+
111
+ function derivePaths(config) {
112
+ const base = path.normalize(config.basePath);
113
+ const music = path.join(base, 'Music');
114
+ config.libraryPath = fs.existsSync(music) ? music : base;
115
+ config.playlistsPath = path.join(base, 'Playlists');
116
+ config.exportPath = path.join(base, 'USB_Output');
117
+ config.lyricsPath = path.join(config.libraryPath, config.lyricsFolderName);
118
+
119
+ const m4a = path.join(config.libraryPath, 'm4a');
120
+ if (!fs.existsSync(m4a) && fs.existsSync(path.join(base, 'm4a'))) {
121
+ config.m4aPath = path.join(base, 'm4a');
122
+ } else {
123
+ config.m4aPath = m4a;
124
+ }
125
+ const mp3 = path.join(config.libraryPath, 'mp3');
126
+ if (!fs.existsSync(mp3) && fs.existsSync(path.join(base, 'mp3'))) {
127
+ config.mp3Path = path.join(base, 'mp3');
128
+ } else {
129
+ config.mp3Path = mp3;
130
+ }
131
+
132
+ if (config.spotubeDownloadPath && config.spotubeDownloadPath.trim()) {
133
+ config.resolvedSpotubeDownloadPath = config.spotubeDownloadPath;
134
+ } else {
135
+ const folder = config.spotubeFolderName || 'spotube';
136
+ config.resolvedSpotubeDownloadPath = path.join(os.homedir(), 'Downloads', folder);
137
+ }
138
+ }