playlist-admin 2.15.22 → 2.15.24

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 CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
 
6
7
  // Thin launcher: the CLI engine lives in the Flutter app binary
7
8
  // (lib/cli_main.dart), so GUI and CLI share one implementation.
@@ -37,17 +38,39 @@ function projectRoot() {
37
38
  }
38
39
  }
39
40
 
41
+ /// npm 包自身目錄(全域 npm i -g 時,cwd 向上找不到 pubspec,
42
+ /// 但包內自帶 cli/ + rag/,RAG 命令靠它就能跑)。
43
+ function packageRoot() {
44
+ try {
45
+ return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
40
51
  function exePath(root) {
41
52
  const candidates = [
42
53
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist-admin.exe'),
43
54
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Debug', 'playlist-admin.exe'),
55
+ path.join(root, 'build', 'windows', 'arm64', 'runner', 'Release', 'playlist-admin.exe'),
56
+ path.join(root, 'build', 'windows', 'arm64', 'runner', 'Debug', 'playlist-admin.exe'),
44
57
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist_administrator.exe'),
45
58
  ];
46
59
  return candidates.find((c) => fs.existsSync(c)) || null;
47
60
  }
48
61
 
49
62
  function python() {
50
- return process.env.PYTHON || 'python';
63
+ if (process.env.PYTHON) return process.env.PYTHON;
64
+ // Windows 常只有 py launcher 或 python:依序探測可用的。
65
+ for (const cmd of ['py', 'python', 'python3']) {
66
+ try {
67
+ const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
68
+ if (r.status === 0) return cmd;
69
+ } catch {
70
+ // 試下一個
71
+ }
72
+ }
73
+ return 'python';
51
74
  }
52
75
 
53
76
  function forward(exe, args) {
@@ -82,15 +105,17 @@ function forwardPy(args) {
82
105
  }
83
106
 
84
107
  function runRag(args) {
85
- const root = projectRoot();
86
- if (!root) {
87
- console.error('找不到專案根目錄(pubspec.yaml)。請在專案內執行,或設定 PA_ROOT');
108
+ const sub = args[0];
109
+ if (sub !== 'build' && sub !== 'query') {
110
+ console.error(`未知 rag 子命令: ${sub ?? '(空)'}\n用法: playlist-admin rag build | playlist-admin rag query "問題"`);
88
111
  process.exit(1);
89
112
  }
90
- const sub = args[0];
91
- const script = path.join(root, 'rag', sub === 'build' ? 'build_db.py' : 'query.py');
92
- if (!fs.existsSync(script)) {
93
- console.error(`找不到 ${script}`);
113
+ // 優先專案內 rag/(開發時最新),全域安裝時退回包內自帶的 rag/。
114
+ const roots = [projectRoot(), packageRoot()].filter(Boolean);
115
+ const fname = sub === 'build' ? 'build_db.py' : 'query.py';
116
+ const script = roots.map((r) => path.join(r, 'rag', fname)).find((s) => fs.existsSync(s));
117
+ if (!script) {
118
+ console.error(`找不到 rag/${fname}。請在專案內執行,或設定 PA_ROOT`);
94
119
  process.exit(1);
95
120
  }
96
121
  forwardPy([script, ...args.slice(1)]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playlist-admin",
3
- "version": "2.15.22",
3
+ "version": "2.15.24",
4
4
  "description": "playlist-admin CLI - thin launcher for the Flutter app engine (pipeline, podcast) + podcast RAG query",
5
5
  "type": "module",
6
6
  "bin": {
package/rag/_resolve.py CHANGED
@@ -32,8 +32,22 @@ def find_base() -> Path:
32
32
  bp = _win_to_wsl(bp)
33
33
  return bp
34
34
 
35
- # WSL: 直接用 /mnt/c/ 路徑
35
+ # WSL:優先 HOME/Music 推導,寫死的個人路徑只當最後 fallback。
36
+ # 多使用者機器:優先 USERNAME 對得上的那個。
36
37
  if _is_wsl():
38
+ home_music = Path.home() / "Music" / "playlist-admin"
39
+ if home_music.exists():
40
+ return home_music
41
+ try:
42
+ hits = sorted(Path("/mnt/c/Users").glob("*/Music/playlist-admin"))
43
+ if hits:
44
+ me = (os.environ.get("USERNAME") or os.environ.get("USER") or "").lower()
45
+ for h in hits:
46
+ if me and h.parts[3].lower() == me:
47
+ return h
48
+ return hits[0]
49
+ except Exception:
50
+ pass
37
51
  return Path("/mnt/c/Users/CPXru/Music/playlist-admin")
38
52
 
39
53
  # Windows: 讀 config.json
@@ -45,7 +59,8 @@ def find_base() -> Path:
45
59
  pointer = Path(local) / "Playlist Administrator" / "data" / "config.json"
46
60
  try:
47
61
  if pointer and pointer.exists():
48
- data = json.loads(pointer.read_text(encoding="utf-8"))
62
+ # utf-8-sig:BOM 檔用 utf-8 讀會讓 json.loads 炸掉。
63
+ data = json.loads(pointer.read_text(encoding="utf-8-sig"))
49
64
  bp = data.get("base_path") or data.get("basePath")
50
65
  if bp:
51
66
  return Path(bp)
package/rag/build_db.py CHANGED
@@ -85,10 +85,12 @@ def read_text_file(path: Path) -> str:
85
85
 
86
86
 
87
87
  def split_sentences(text: str) -> list[str]:
88
- """依中文句號斷句,過濾空白行"""
89
- text = re.sub(r"\s+", "", text)
88
+ """依中文句號斷句,過濾空白行。
89
+ 空白壓成單空格不斷字:舊寫法刪掉所有空白,英文全黏在一起傷 embedding。
90
+ (query.py 的 normalize 已同步改同一規則,否則 offset 對不上。)"""
91
+ text = re.sub(r"\s+", " ", text).strip()
90
92
  parts = re.split(r"(?<=[。!?!?;;])", text)
91
- return [p for p in parts if len(p) >= 4]
93
+ return [p.strip() for p in parts if len(p.strip()) >= 4]
92
94
 
93
95
 
94
96
  def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) -> list[str]:
@@ -135,7 +137,41 @@ def main() -> None:
135
137
  ap.add_argument("--batch", type=int, default=64, help="每批 embedding 數量")
136
138
  args = ap.parse_args()
137
139
 
140
+ # 先拿鎖再開 PersistentClient:反過來雙進程仍會同時開 SQLite。
141
+ lock_file = Path(args.db) / ".build.lock"
142
+ if lock_file.exists():
143
+ # stale 鎖:mtime 超過 30 分鐘(kill -9 殘留)直接清掉重拿。
144
+ try:
145
+ age = time.time() - lock_file.stat().st_mtime
146
+ except Exception:
147
+ age = 0
148
+ if age < 1800:
149
+ print("另一個 build_db 正在寫入此 DB,本次跳過(避免 ChromaDB 寫入衝突)")
150
+ sys.exit(2)
151
+ try:
152
+ lock_file.unlink()
153
+ print("清掉過期 build lock,繼續執行")
154
+ except Exception:
155
+ pass
156
+ try:
157
+ fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
158
+ with os.fdopen(fd, "w") as lf:
159
+ lf.write(f"{os.getpid()}|{time.time()}")
160
+ except FileExistsError:
161
+ print("另一個 build_db 正在寫入此 DB,本次跳過(避免 ChromaDB 寫入衝突)")
162
+ sys.exit(2)
163
+
138
164
  client = chromadb.PersistentClient(path=args.db)
165
+ try:
166
+ _main_build(client, args)
167
+ finally:
168
+ try:
169
+ lock_file.unlink()
170
+ except Exception:
171
+ pass
172
+
173
+
174
+ def _main_build(client, args) -> None:
139
175
  if args.reset:
140
176
  try:
141
177
  client.delete_collection("podcasts")
@@ -192,13 +228,16 @@ def main() -> None:
192
228
  return hashlib.md5(raw).hexdigest()
193
229
 
194
230
  migrated = 0
231
+ to_update_ids: list[str] = []
232
+ to_update_metas: list[dict] = []
195
233
  offset = 0
196
234
  while True:
197
- batch = col.get(limit=5000, offset=offset,
198
- include=["metadatas"])["metadatas"]
235
+ got = col.get(limit=5000, offset=offset, include=["metadatas"])
236
+ batch = got["metadatas"]
237
+ ids = got["ids"]
199
238
  if not batch:
200
239
  break
201
- for m in batch:
240
+ for rid, m in zip(ids, batch):
202
241
  if not m or m.get("sig"):
203
242
  continue
204
243
  p = m.get("path")
@@ -206,13 +245,26 @@ def main() -> None:
206
245
  continue
207
246
  try:
208
247
  sig = file_sig(Path(p))
248
+ m["sig"] = sig
209
249
  done_sigs.add(sig)
250
+ to_update_ids.append(rid)
251
+ to_update_metas.append(m)
210
252
  migrated += 1
211
253
  except Exception:
212
254
  pass
213
255
  offset += 5000
214
256
  if offset > 1000000:
215
257
  break
258
+ # 寫回 DB:舊寫法只加記憶體 set,從不寫回,每次啟動重掃且刪檔殘留。
259
+ for i in range(0, len(to_update_ids), 4500):
260
+ try:
261
+ col.update(
262
+ ids=to_update_ids[i:i + 4500],
263
+ metadatas=to_update_metas[i:i + 4500],
264
+ )
265
+ except Exception as e:
266
+ print(f"migration write-back failed: {e}")
267
+ break
216
268
  if migrated:
217
269
  print(f"migrated {migrated} legacy chunks to content-sig dedup")
218
270
 
@@ -231,9 +283,31 @@ def main() -> None:
231
283
  def has_content(path: Path) -> bool:
232
284
  return bool(make_chunks(split_sentences(read_text_file(path))))
233
285
 
286
+ # 過濾非 podcast 的 .txt:目錄黑名單 + 檔名/大小門檻
287
+ _SKIP_DIRS = {'podcast_rag', '__pycache__', 'chroma_db', 'node_modules', '.git'}
288
+ _SKIP_STEMS = {'README', 'changelog', 'license', 'requirements', 'setup', 'config'}
289
+ _MIN_TXT_BYTES = 100 # podcast 逐字稿至少 ~100 bytes
290
+
291
+ def _is_valid_podcast_txt(f: Path) -> bool:
292
+ # 跳過黑名單目錄下的所有檔案
293
+ for part in f.relative_to(Path(args.data)).parts[:-1]:
294
+ if part in _SKIP_DIRS:
295
+ return False
296
+ # 跳過已知非逐字稿檔名
297
+ if f.stem.lower() in _SKIP_STEMS:
298
+ return False
299
+ # 跳過太短的檔案(通常是設定檔或說明)
300
+ try:
301
+ if f.stat().st_size < _MIN_TXT_BYTES:
302
+ return False
303
+ except OSError:
304
+ return False
305
+ return True
306
+
234
307
  files = [
235
308
  f for f in sorted(Path(args.data).rglob("*.txt"))
236
- if file_sig(f) not in done_sigs
309
+ if _is_valid_podcast_txt(f)
310
+ and file_sig(f) not in done_sigs
237
311
  and not (f.stem in skipped and not has_content(f))
238
312
  ]
239
313
  if args.limit:
@@ -266,23 +340,37 @@ def main() -> None:
266
340
  sig = file_sig(f)
267
341
  try:
268
342
  kept_chunks, vectors = embed_texts(args.model, chunks, batch=args.batch, workers=args.workers)
269
- except requests.exceptions.HTTPError as e:
270
- print(f"!! HTTP 錯誤 檔案={meta['file']} 前300字={chunks[0][:300] if chunks else ''}")
271
- raise
272
343
  except requests.exceptions.ConnectionError:
273
344
  print("無法連線 Ollama,請先啟動 (ollama serve) 並 pull bge-m3")
274
345
  sys.exit(1)
346
+ except Exception as e:
347
+ # 單檔失敗(500/429/timeout)不可中斷整個索引:
348
+ # 跳過,下次增量 run 會自動補上(dedup:沒寫入就不在 done_sigs)。
349
+ print(f"!! 跳過 {meta['file']}: {e}")
350
+ continue
275
351
  if not kept_chunks:
276
352
  continue
353
+ # ID 混入 show+sig+檔名:同節目同內容不同檔也互不相撞;
354
+ # 寫入成功立刻登記 done_sigs(批次內第二檔直接跳過,不等下次 run)。
355
+ try:
356
+ col.delete(where={"path": str(f)})
357
+ except Exception as e:
358
+ print(f"!! 清舊 chunks 失敗 {meta['file']}: {e}")
277
359
  ids = [
278
- hashlib.md5(f"{meta['file']}|{j}".encode()).hexdigest()
360
+ hashlib.md5(f"{meta['show']}|{meta['file']}|{sig}|{j}".encode()).hexdigest()
279
361
  for j in range(len(kept_chunks))
280
362
  ]
281
363
  metadatas = [
282
364
  {**meta, "sig": sig, "chunk": j, "chars": len(c), "path": str(f)}
283
365
  for j, c in enumerate(kept_chunks)
284
366
  ]
285
- col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
367
+ try:
368
+ col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
369
+ except Exception as e:
370
+ # DuplicateID 等單檔失敗不可崩整個 build:下次增量補上。
371
+ print(f"!! 寫入失敗跳過 {meta['file']}: {e}")
372
+ continue
373
+ done_sigs.add(sig)
286
374
  total_chunks += len(kept_chunks)
287
375
  if i % 5 == 0 or i == len(files):
288
376
  elapsed = time.time() - t_start
package/rag/query.py CHANGED
@@ -36,7 +36,9 @@ def embed_one(model: str, text: str) -> list[float]:
36
36
 
37
37
 
38
38
  def normalize(text: str) -> str:
39
- return re.sub(r"\s+", "", text)
39
+ # 與 build_db.split_sentences 同規則(單空格),否則 chunk offset 對不上。
40
+ # 注意:此規則變更前的舊 chunks 是無空白版,offset 可能為 -1(僅顯示用,不影響檢索)。
41
+ return re.sub(r"\s+", " ", text).strip()
40
42
 
41
43
 
42
44
  def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
@@ -64,8 +66,9 @@ def pick_chat_models() -> list[str]:
64
66
  return [*local, *cloud]
65
67
 
66
68
 
67
- def generate_answer(question: str, hits: list[dict]) -> str:
68
- """用 Ollama 生成基於檢索片段的回答;全部模型都失敗就回傳錯誤訊息"""
69
+ def generate_answer(question: str, hits: list[dict], quiet: bool = False) -> str:
70
+ """用 Ollama 生成基於檢索片段的回答;全部模型都失敗就回傳錯誤訊息。
71
+ quiet=True 時不印 token(--json 模式:stdout 只能有 JSON,否則解析失敗)。"""
69
72
  context = "\n".join(
70
73
  f"[{h['show']} | {h['date']} | {h['file']}]\n{h['chunk']}"
71
74
  for h in hits
@@ -101,10 +104,12 @@ def generate_answer(question: str, hits: list[dict]) -> str:
101
104
  token = chunk.get("message", {}).get("content", "")
102
105
  if token:
103
106
  answer.append(token)
104
- print(token, end="", flush=True)
107
+ if not quiet:
108
+ print(token, end="", flush=True)
105
109
  except json.JSONDecodeError:
106
110
  pass
107
- print() # 換行
111
+ if not quiet:
112
+ print() # 換行
108
113
  return "".join(answer).strip()
109
114
  except requests.RequestException as e:
110
115
  errors.append(f"{model}: {e}")
@@ -139,9 +144,10 @@ def main() -> None:
139
144
  res = col.query(query_embeddings=[qvec], n_results=fetch_n)
140
145
 
141
146
  raw_hits = []
142
- for doc, meta, dist in zip(
143
- res["documents"][0], res["metadatas"][0], res["distances"][0]
144
- ):
147
+ docs = res["documents"][0] if res.get("documents") else []
148
+ metas = res["metadatas"][0] if res.get("metadatas") else []
149
+ dists = res["distances"][0] if res.get("distances") else []
150
+ for doc, meta, dist in zip(docs, metas, dists):
145
151
  if args.show and args.show not in meta.get("show", ""):
146
152
  continue
147
153
  raw_hits.append({
@@ -181,20 +187,32 @@ def main() -> None:
181
187
  ],
182
188
  }
183
189
  if not args.no_full and e["path"]:
184
- full = read_text_file(Path(e["path"]))
185
- full_norm = normalize(full)
186
- entry["full_text"] = full
187
- entry["full_text_norm_len"] = len(full_norm)
188
- entry["offsets"] = [
189
- locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
190
- ]
190
+ # 檔案被搬走/刪除時跳過全文(舊寫法直接拋 FileNotFoundError 整個查詢崩潰)。
191
+ # 缺檔也要寫固定鍵:下游取 full_text/offsets 不可 KeyError。
192
+ try:
193
+ full = read_text_file(Path(e["path"]))
194
+ except (OSError, FileNotFoundError):
195
+ full = ""
196
+ if full:
197
+ full_norm = normalize(full)
198
+ entry["full_text"] = full
199
+ entry["full_text_norm_len"] = len(full_norm)
200
+ entry["offsets"] = [
201
+ locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
202
+ ]
203
+ else:
204
+ entry["full_text"] = ""
205
+ entry["full_text_norm_len"] = 0
206
+ entry["offsets"] = []
191
207
  results.append(entry)
192
208
 
193
209
  if args.json:
194
210
  payload = {"question": args.question, "results": results}
195
211
  if args.answer:
196
212
  try:
197
- payload["answer"] = generate_answer(args.question, raw_hits[: max(args.topk_answer, 3)])
213
+ payload["answer"] = generate_answer(
214
+ args.question, raw_hits[: max(args.topk_answer, 3)], quiet=True
215
+ )
198
216
  except Exception as e:
199
217
  payload["answer_error"] = f"(Ollama 生成失敗: {e})"
200
218
  out = json.dumps(payload, ensure_ascii=False, indent=2)
@@ -11,8 +11,20 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
11
  from _resolve import podcast_downloads_dir
12
12
 
13
13
 
14
+ def read_srt(srt_path: Path) -> str:
15
+ """讀檔:utf-8-sig → utf-8 → big5 → gb18030,和 build_db 同順序。
16
+ 舊寫法只試 utf-8-sig,Big5 繁中字幕直接亂碼進庫。"""
17
+ raw = srt_path.read_bytes()
18
+ for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
19
+ try:
20
+ return raw.decode(enc)
21
+ except UnicodeDecodeError:
22
+ continue
23
+ return raw.decode("utf-8", errors="replace")
24
+
25
+
14
26
  def srt_to_text(srt_path: Path) -> str:
15
- text = srt_path.read_text(encoding="utf-8-sig", errors="replace")
27
+ text = read_srt(srt_path)
16
28
  # 移除 srt 索引行 (純數字行)
17
29
  text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
18
30
  # 移除時間軸行 (00:00:00,000 --> 00:00:01,000)