playlist-admin 2.7.13 → 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 +73 -24
- package/cli/index.js +2 -4
- package/package.json +3 -7
- package/rag/_resolve.py +33 -4
- package/rag/build_db.py +96 -10
- package/rag/query.py +16 -2
package/README.md
CHANGED
|
@@ -1,37 +1,84 @@
|
|
|
1
1
|
# playlist-admin
|
|
2
2
|
|
|
3
|
-
A Flutter desktop app for music library management
|
|
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
|
-
- **
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
|
18
|
-
flutter build
|
|
45
|
+
flutter build windows --release # 桌面版
|
|
46
|
+
flutter build apk --release # Android(手機版)
|
|
47
|
+
flutter build ios --release # iOS(需 macOS + Xcode)
|
|
19
48
|
```
|
|
20
49
|
|
|
21
|
-
##
|
|
50
|
+
## 一起聽(Jam)
|
|
22
51
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
43
|
-
git push origin v2.
|
|
89
|
+
git tag v2.9.0
|
|
90
|
+
git push origin v2.9.0
|
|
44
91
|
```
|
|
45
92
|
|
|
46
93
|
## Dependencies
|
|
47
94
|
|
|
48
|
-
- `
|
|
49
|
-
- `
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
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
|
|
20
|
-
playlist-admin
|
|
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.
|
|
4
|
-
"description": "playlist-admin CLI - thin launcher for the Flutter app engine (pipeline, podcast
|
|
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
|
-
|
|
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\
|
|
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() / "
|
|
58
|
+
return find_base() / "podcasts"
|
|
30
59
|
|
|
31
60
|
|
|
32
61
|
def rag_data_dir() -> Path:
|
|
33
|
-
return find_base() / "
|
|
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
|
-
|
|
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
|
-
#
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)} 篇 (
|
|
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":
|
|
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
|
-
|
|
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:
|