playlist-admin 2.7.15 → 2.15.14

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/README.md CHANGED
@@ -1,37 +1,84 @@
1
1
  # playlist-admin
2
2
 
3
- A Flutter desktop app for music library management and Spotube playlist automation.
3
+ A Flutter desktop app for Spotify-native music library management: browse the
4
+ Spotify home feed, search & stream, playlist sync, batch downloads, and stats.
4
5
 
5
6
  ## Features
6
7
 
7
- - **Spotube Automation** — Automatically download playlists via Spotube GUI
8
- - **Audio Conversion** — M4A → MP3 with batch processing
9
- - **Spotify Scraping** — Fetch playlists via Spotify Embed API
10
- - **Playlist Management** — Track completeness, prune missing files
11
- - **Statistics** — Library size, format distribution, coverage charts
8
+ - **Spotify 原生整合** — 個人化主頁 / 搜尋串流 / 你的歌單(內部 GQL API,WebView 登入)
9
+ - **串流播放** — Spotify 搜尋 → yt-dlp 解析 → ffmpeg VBR 0 轉碼 → 本地 HTTP 串流(可選快取 cache\stream\)
10
+ - **原生下載** — yt-dlp 256k Opus → mp3 VBR 0(`preferredquality: 0`),批量一鍵補全
11
+ - **播放清單同步** — Spotify embed API 抓取 → m3u8 + 自動下載缺漏曲目
12
+ - **工作列媒體控制器** — SMTC 進度條 + 拖動 seek + 播放/暫停/上下首
13
+ - **播放佇列** — 拖曳排序 / 移除 / 跳轉 / play next,重啟還原(cache\queue.json)
14
+ - **播放統計** — 50%/4min scrobble 規則 → Top 曲目 / 聆聽分鐘 / 最近播放
15
+ - **下載統計** — 每輪下載成功率紀錄(cache\downloads_log.json)
16
+ - **Sleep timer / Crossfade / 全域熱鍵**(Space、←/→ seek、N/P 切歌)
17
+ - **Podcast + RAG** — RSS 下載、Groq 轉錄、Ollama chroma 問答
18
+ - **Discord RPC** — 現播歌曲 + 封面
19
+ - **一起聽(Jam)** — 免費版 Spotify Jam:開房間(免註冊/免外部伺服器),
20
+ 同一個 Wi-Fi 下大家加入,共享播放佇列、即時同步播放進度、投票跳歌、
21
+ 聊天室。手機(Android/iOS)也能加入房主一起聽。
22
+
23
+ ## 資料夾結構(`Music\playlist-admin\`)
24
+
25
+ ```
26
+ music\ ← 唯一音樂庫(mp3)
27
+ playlists\ ← m3u8
28
+ lyrics\ ← LRC
29
+ podcasts\ ← 播客音檔
30
+ cache\spotify ← Spotify 快取 / snapshot / session
31
+ cache\podcast ← 播客處理快取 / chroma_db
32
+ cache\lufs ← LUFS 快取
33
+ cache\stream ← 串流快取
34
+ cache\thumbnails
35
+ exports\ ← USB 匯出
36
+ logs\ ← 日誌
37
+ tools\ ← bridge 腳本
38
+ config.json ← 設定
39
+ ```
12
40
 
13
41
  ## Build
14
42
 
15
43
  ```bash
16
44
  flutter pub get
17
- flutter create --platforms=windows .
18
- flutter build windows --release
45
+ flutter build windows --release # 桌面版
46
+ flutter build apk --release # Android(手機版)
47
+ flutter build ios --release # iOS(需 macOS + Xcode)
19
48
  ```
20
49
 
21
- ## CLI
50
+ ## 一起聽(Jam)
22
51
 
23
- ```bash
24
- dart run lib/cli_main.dart pipeline
25
- dart run lib/cli_main.dart spotube-download "Daily Mix 1"
26
- dart run lib/cli_main.dart spotube-download-all
27
- dart run lib/cli_main.dart spotube-move
28
- dart run lib/cli_main.dart status
29
- ```
52
+ 在側邊欄選「一起聽」:
53
+
54
+ 1. **建立房間** — 輸入暱稱 →「建立新房間」。房主端會自動開啟
55
+ WebSocket + 音訊串流伺服器,並顯示房主位址(`IP:埠口`)與 6 位房間代碼。
56
+ 2. **加入房間** — 對方輸入房主位址 + 房間代碼 + 暱稱即可加入
57
+ (手機/桌機皆可;手機需與房主連同一個 Wi-Fi)。
58
+ 3. 任何人都能**搜尋 Spotify 加歌**(房主需先在本機「搜尋」頁登入 Spotify)、
59
+ **投票**(上下箭頭排序)、**投票跳歌**(達半數跳過)、**聊天**。
60
+ 播放進度由房主掌控,成員每幾秒做 drift 校正,確保同步。
61
+
62
+ 技術重點:
30
63
 
31
- ## Calibration
64
+ - 房主是「伺服器」:`lib/services/jam_service.dart` 用 `dart:io` 自架
65
+ HttpServer + WebSocket,成員連 `ws://<ip>:<port>/jam/ws`。
66
+ - 歌曲來源:本機音樂庫(`local`,`/jam/audio` 支援 Range 串流)或
67
+ Spotify 搜尋後由 yt-dlp 串流(`stream`,走既有的 `StreamServer`)。
68
+ - 成員端透過 `PlayerController.playJamUrl()` 播放房主提供的 URL,
69
+ `jamFollowMode` 讓播放/暫停/上下首/seek 指令自動轉送給房主。
70
+ - 手機相容:Windows 專用程式碼(win32 單一實例、SMTC、WebView 登入)
71
+ 都已加平台守衛;Android/iOS 已加入 `flutter create` 平台資料夾與
72
+ 網路權限(INTERNET / cleartext / 本地網路描述)。
73
+
74
+ ## CLI
32
75
 
33
76
  ```bash
34
- dart run lib/calibrate.dart
77
+ playlist-admin pipeline [--step N] # 全管線(抓取→下載→修剪→整理→LUFS)
78
+ playlist-admin podcast # Podcast 管線
79
+ playlist-admin status # 狀態
80
+ playlist-admin favorite list|toggle # 我的最愛
81
+ playlist-admin rag build|query # Podcast RAG
35
82
  ```
36
83
 
37
84
  ## Release
@@ -39,13 +86,15 @@ dart run lib/calibrate.dart
39
86
  Push a `v*` tag to trigger GitHub Actions:
40
87
 
41
88
  ```bash
42
- git tag v2.0.0
43
- git push origin v2.0.0
89
+ git tag v2.9.0
90
+ git push origin v2.9.0
44
91
  ```
45
92
 
46
93
  ## Dependencies
47
94
 
48
- - `win32` — Windows API (Spotube automation)
49
- - `http` + `html` — Spotify scraping
50
- - `fl_chart` — Charts
51
- - `ffi` — FFI support
95
+ - `http` + `html` — Spotify embed API
96
+ - `crypto` — TOTP(Spotify token)
97
+ - `webview_windows`(vendor)— 登入 cookie 擷取
98
+ - `audioplayers` — 播放
99
+ - `fl_chart` — 圖表
100
+ - `flutter_discord_rpc` — Discord presence
package/cli/index.js CHANGED
@@ -16,10 +16,8 @@ Usage:
16
16
  playlist-admin pipeline [--step N] Run full pipeline (or from step N)
17
17
  playlist-admin podcast Run podcast pipeline
18
18
  playlist-admin status Show status
19
- playlist-admin spotube-download <name> Download one playlist
20
- playlist-admin spotube-download-all Download all playlists
21
- playlist-admin spotube-move Move M4A files
22
- playlist-admin spotube-cleanup Remove metadata-renamed mp3 duplicates
19
+ playlist-admin favorite list List favorite songs
20
+ playlist-admin favorite toggle <song> Toggle favorite (我的最愛)
23
21
  playlist-admin rag build [--reset] Build podcast RAG vector DB
24
22
  playlist-admin rag query "問題" [--topk N] [--show 節目] [--json]
25
23
  `;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "playlist-admin",
3
- "version": "2.7.15",
4
- "description": "playlist-admin CLI - thin launcher for the Flutter app engine (pipeline, podcast, spotube) + podcast RAG query",
3
+ "version": "2.15.14",
4
+ "description": "playlist-admin CLI - thin launcher for the Flutter app engine (pipeline, podcast) + podcast RAG query",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "playlist-admin": "./cli/index.js"
@@ -9,11 +9,7 @@
9
9
  "scripts": {
10
10
  "pipeline": "node cli/index.js pipeline",
11
11
  "podcast": "node cli/index.js podcast",
12
- "status": "node cli/index.js status",
13
- "spotube-download": "node cli/index.js spotube-download",
14
- "spotube-download-all": "node cli/index.js spotube-download-all",
15
- "spotube-move": "node cli/index.js spotube-move",
16
- "spotube-cleanup": "node cli/index.js spotube-cleanup"
12
+ "status": "node cli/index.js status"
17
13
  },
18
14
  "files": [
19
15
  "cli/index.js",
package/rag/_resolve.py CHANGED
@@ -1,13 +1,42 @@
1
1
  """Resolve podcast RAG paths from the app config (same as the CLI/GUI)."""
2
2
  import json
3
3
  import os
4
+ import platform
4
5
  from pathlib import Path
5
6
 
6
7
 
8
+ def _is_wsl() -> bool:
9
+ """偵測是否在 WSL 中執行。"""
10
+ if "microsoft" in platform.release().lower():
11
+ return True
12
+ if os.path.exists("/proc/sys/fs/binfmt_misc/WSLInterop"):
13
+ return True
14
+ return False
15
+
16
+
17
+ def _win_to_wsl(path: Path) -> Path:
18
+ """將 Windows 路徑轉為 WSL /mnt/c/... 路徑。"""
19
+ s = str(path)
20
+ if len(s) >= 2 and s[1] == ":":
21
+ drive = s[0].lower()
22
+ rest = s[2:].replace("\\", "/")
23
+ return Path(f"/mnt/{drive}{rest}")
24
+ return path
25
+
26
+
7
27
  def find_base() -> Path:
8
28
  env_bp = os.environ.get("BASE_PATH")
9
29
  if env_bp:
10
- return Path(env_bp)
30
+ bp = Path(env_bp)
31
+ if _is_wsl():
32
+ bp = _win_to_wsl(bp)
33
+ return bp
34
+
35
+ # WSL: 直接用 /mnt/c/ 路徑
36
+ if _is_wsl():
37
+ return Path("/mnt/c/Users/CPXru/Music/playlist-admin")
38
+
39
+ # Windows: 讀 config.json
11
40
  local = os.environ.get("LOCALAPPDATA")
12
41
  pointer = None
13
42
  if local:
@@ -22,15 +51,15 @@ def find_base() -> Path:
22
51
  return Path(bp)
23
52
  except Exception:
24
53
  pass
25
- return Path(r"C:\Users\CPXru\Music\Spotube")
54
+ return Path(r"C:\Users\CPXru\Music\playlist-admin")
26
55
 
27
56
 
28
57
  def podcast_downloads_dir() -> Path:
29
- return find_base() / "podcast_downloads"
58
+ return find_base() / "podcasts"
30
59
 
31
60
 
32
61
  def rag_data_dir() -> Path:
33
- return find_base() / "podcast_downloads" / "podcast_rag"
62
+ return find_base() / "cache" / "podcast"
34
63
 
35
64
 
36
65
  def chroma_db_dir() -> Path:
package/rag/build_db.py CHANGED
@@ -114,13 +114,14 @@ def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) ->
114
114
 
115
115
 
116
116
  def detect_meta(path: Path) -> dict:
117
- """從檔名/路徑猜節目與日期"""
117
+ """從檔名/路徑猜節目、日期與來源類型"""
118
118
  parts = path.parts
119
119
  show = parts[-2] if len(parts) >= 2 else "unknown"
120
120
  name = path.stem
121
121
  m = re.search(r"(20\d{2})[_\-_](\d{1,2})[_\-_](\d{1,2})", name)
122
122
  date = f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}" if m else ""
123
- return {"show": show, "file": name, "date": date}
123
+ source = "course" if re.search(r"夜工智|機械群|課程", str(path)) else "podcast"
124
+ return {"show": show, "file": name, "date": date, "source": source}
124
125
 
125
126
 
126
127
  def main() -> None:
@@ -131,6 +132,7 @@ def main() -> None:
131
132
  ap.add_argument("--reset", action="store_true", help="重建資料庫")
132
133
  ap.add_argument("--limit", type=int, default=0, help="只索引前 N 篇 (測試用)")
133
134
  ap.add_argument("--workers", type=int, default=6, help="併發 embedding 數")
135
+ ap.add_argument("--batch", type=int, default=64, help="每批 embedding 數量")
134
136
  args = ap.parse_args()
135
137
 
136
138
  client = chromadb.PersistentClient(path=args.db)
@@ -144,24 +146,99 @@ def main() -> None:
144
146
  metadata={"hnsw:space": "cosine"},
145
147
  )
146
148
 
147
- # 已索引的檔名 (resume 用) - 分段讀取避免 SQL 變數超限
148
- done_files = set()
149
+ # ── Dedup: remove duplicate sig entries, keep first only ──────
150
+ total = col.count()
151
+ if total > 0:
152
+ sig_to_ids: dict[str, list[str]] = {}
153
+ offset = 0
154
+ while offset < total:
155
+ batch = col.get(limit=5000, offset=offset, include=["metadatas"])
156
+ ids = batch["ids"]
157
+ metas = batch["metadatas"]
158
+ for rid, m in zip(ids, metas):
159
+ sig = (m or {}).get("sig", "")
160
+ if sig:
161
+ sig_to_ids.setdefault(sig, []).append(rid)
162
+ offset += 5000
163
+ to_delete = [ids[1:] for ids in sig_to_ids.values() if len(ids) > 1]
164
+ flat_delete = [item for sublist in to_delete for item in sublist]
165
+ if flat_delete:
166
+ print(f"Dedup: removing {len(flat_delete)} duplicate records...")
167
+ for i in range(0, len(flat_delete), 4500):
168
+ col.delete(ids=flat_delete[i:i+4500])
169
+ print(f"Dedup done. New count: {col.count()}")
170
+
171
+ # 已索引內容 hash 去重 - 分段讀取避免 SQL 變數超限
172
+ done_sigs: set[str] = set()
149
173
  offset = 0
150
174
  while True:
151
175
  batch = col.get(limit=5000, offset=offset, include=["metadatas"])["metadatas"]
152
176
  if not batch:
153
177
  break
154
178
  for m in batch:
155
- if m:
156
- done_files.add(m.get("file", ""))
179
+ if m and m.get("sig"):
180
+ done_sigs.add(m["sig"])
181
+ offset += 5000
182
+ if offset > 1000000:
183
+ break
184
+
185
+ # 一次性遷移: 舊版 chunks 沒有 sig — 從 metadata 的 path 讀檔補上,
186
+ # 避免 1662 篇全部被當新的重嵌入。
187
+ def file_sig(path: Path) -> str:
188
+ raw = path.read_bytes()
189
+ # Strip BOM and normalize whitespace to avoid false re-embeds
190
+ if raw.startswith(b'\xef\xbb\xbf'):
191
+ raw = raw[3:]
192
+ return hashlib.md5(raw).hexdigest()
193
+
194
+ migrated = 0
195
+ offset = 0
196
+ while True:
197
+ batch = col.get(limit=5000, offset=offset,
198
+ include=["metadatas"])["metadatas"]
199
+ if not batch:
200
+ break
201
+ for m in batch:
202
+ if not m or m.get("sig"):
203
+ continue
204
+ p = m.get("path")
205
+ if not p or not os.path.exists(p):
206
+ continue
207
+ try:
208
+ sig = file_sig(Path(p))
209
+ done_sigs.add(sig)
210
+ migrated += 1
211
+ except Exception:
212
+ pass
157
213
  offset += 5000
158
214
  if offset > 1000000:
159
215
  break
216
+ if migrated:
217
+ print(f"migrated {migrated} legacy chunks to content-sig dedup")
160
218
 
161
- files = [f for f in sorted(Path(args.data).rglob("*.txt")) if f.stem not in done_files]
219
+ # 無內容/失敗檔案的跳過清單:0 chunks 的檔案不會寫進 DB,
220
+ # 若沒有持久狀態會每次 pipeline 都被當「待索引」重試。
221
+ skip_state_file = Path(args.db) / "_skipped_files.json"
222
+ skipped: set[str] = set()
223
+ if skip_state_file.exists():
224
+ try:
225
+ import json
226
+ skipped = set(json.loads(skip_state_file.read_text(encoding="utf-8")))
227
+ except Exception:
228
+ skipped = set()
229
+
230
+ # 檔案內容 hash — 內容更新(來源切換)時 sig 變 → 重嵌入
231
+ def has_content(path: Path) -> bool:
232
+ return bool(make_chunks(split_sentences(read_text_file(path))))
233
+
234
+ files = [
235
+ f for f in sorted(Path(args.data).rglob("*.txt"))
236
+ if file_sig(f) not in done_sigs
237
+ and not (f.stem in skipped and not has_content(f))
238
+ ]
162
239
  if args.limit:
163
240
  files = files[: args.limit]
164
- print(f"待索引 {len(files)} 篇 (已跳過 {len(done_files)} 篇)")
241
+ print(f"待索引 {len(files)} 篇 (已嵌入 {len(done_sigs)} 篇, 無內容跳過 {len(skipped)} 篇)")
165
242
 
166
243
  total_chunks = 0
167
244
  t_start = time.time()
@@ -176,10 +253,19 @@ def main() -> None:
176
253
  )
177
254
  chunks = final_chunks
178
255
  if not chunks:
256
+ # 空/太短內容: 標記為已跳過, 避免每次重試
257
+ skipped.add(f.stem)
258
+ import json
259
+ skip_state_file.parent.mkdir(parents=True, exist_ok=True)
260
+ skip_state_file.write_text(
261
+ json.dumps(sorted(skipped), ensure_ascii=False, indent=1),
262
+ encoding="utf-8",
263
+ )
179
264
  continue
180
265
  meta = detect_meta(f)
266
+ sig = file_sig(f)
181
267
  try:
182
- kept_chunks, vectors = embed_texts(args.model, chunks, workers=args.workers)
268
+ kept_chunks, vectors = embed_texts(args.model, chunks, batch=args.batch, workers=args.workers)
183
269
  except requests.exceptions.HTTPError as e:
184
270
  print(f"!! HTTP 錯誤 檔案={meta['file']} 前300字={chunks[0][:300] if chunks else ''}")
185
271
  raise
@@ -193,7 +279,7 @@ def main() -> None:
193
279
  for j in range(len(kept_chunks))
194
280
  ]
195
281
  metadatas = [
196
- {**meta, "chunk": j, "chars": len(c), "path": str(f)}
282
+ {**meta, "sig": sig, "chunk": j, "chars": len(c), "path": str(f)}
197
283
  for j, c in enumerate(kept_chunks)
198
284
  ]
199
285
  col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
package/rag/query.py CHANGED
@@ -83,15 +83,29 @@ def generate_answer(question: str, hits: list[dict]) -> str:
83
83
  json={
84
84
  "model": model,
85
85
  "messages": [{"role": "user", "content": prompt}],
86
- "stream": False,
86
+ "stream": True,
87
87
  },
88
88
  timeout=600,
89
+ stream=True,
89
90
  )
90
91
  if resp.status_code in (400, 401, 404):
91
92
  errors.append(f"{model}: HTTP {resp.status_code}")
92
93
  continue
93
94
  resp.raise_for_status()
94
- return resp.json()["message"]["content"].strip()
95
+ answer = []
96
+ for line in resp.iter_lines():
97
+ if not line:
98
+ continue
99
+ try:
100
+ chunk = json.loads(line)
101
+ token = chunk.get("message", {}).get("content", "")
102
+ if token:
103
+ answer.append(token)
104
+ print(token, end="", flush=True)
105
+ except json.JSONDecodeError:
106
+ pass
107
+ print() # 換行
108
+ return "".join(answer).strip()
95
109
  except requests.RequestException as e:
96
110
  errors.append(f"{model}: {e}")
97
111
  if errors: