main.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. from __future__ import annotations
  2. import json
  3. import mimetypes
  4. import shutil
  5. import threading
  6. import uuid
  7. import base64
  8. import re
  9. import urllib.error
  10. import urllib.parse
  11. import urllib.request
  12. from datetime import datetime, timezone
  13. from pathlib import Path
  14. from typing import Any
  15. from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
  16. from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
  17. from fastapi.staticfiles import StaticFiles
  18. from fastapi.templating import Jinja2Templates
  19. from pydantic import BaseModel
  20. BASE_DIR = Path(__file__).resolve().parent.parent
  21. LOCAL_MUSIC_DIR = BASE_DIR / "mp3file"
  22. CLOUD_MUSIC_DIR = Path("/mnt/baiducloud/百度网盘/mp3file")
  23. CLOUD_WEBDAV_BASE = "http://110.42.102.94:5244/dav"
  24. CLOUD_ALIST_BASE = "http://110.42.102.94:5244"
  25. def _nat_sort_key(name: str) -> tuple:
  26. """Sort key that treats numeric substrings as integers for natural ordering."""
  27. parts = re.split(r'(\d+)', name.lower())
  28. result = []
  29. for part in parts:
  30. if part.isdigit():
  31. result.append((0, int(part), part))
  32. else:
  33. result.append((1, 0, part))
  34. return result
  35. CLOUD_WEBDAV_USER = "sequoia00"
  36. CLOUD_WEBDAV_PASSWORD = "792199bb"
  37. MUSIC_ROOTS: list[tuple[str, Path, str]] = [
  38. ("", LOCAL_MUSIC_DIR, "本地音乐"),
  39. ("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
  40. ]
  41. PLAYLISTS_FILE = BASE_DIR / "playlists.json"
  42. CACHE_DIR = BASE_DIR / ".cache"
  43. CLOUD_LIBRARY_CACHE_FILE = CACHE_DIR / "cloud_library.json"
  44. CLOUD_COVER_CACHE_DIR = CACHE_DIR / "cloud_covers"
  45. SUPPORTED_EXTENSIONS = {
  46. ".mp3",
  47. ".wav",
  48. ".flac",
  49. ".m3u",
  50. ".m3u8",
  51. ".ogg",
  52. ".aac",
  53. ".wma",
  54. ".opus",
  55. ".oga",
  56. ".mp4",
  57. ".m4a",
  58. ".webm",
  59. }
  60. IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
  61. app = FastAPI(title="MusicWeb", version="1.0.0")
  62. app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
  63. templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
  64. CLOUD_REFRESH_STATE = {"running": False}
  65. CLOUD_REFRESH_LOCK = threading.Lock()
  66. _ALIST_TOKEN_CACHE: dict[str, tuple[str, float]] = {}
  67. class FolderCreateRequest(BaseModel):
  68. path: str
  69. class MoveRequest(BaseModel):
  70. source: str
  71. destination_dir: str
  72. class PlaylistCreateRequest(BaseModel):
  73. name: str
  74. tracks: list[str]
  75. class PlaylistUpdateRequest(BaseModel):
  76. tracks: list[str]
  77. def ensure_storage() -> None:
  78. LOCAL_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
  79. CLOUD_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
  80. CACHE_DIR.mkdir(parents=True, exist_ok=True)
  81. CLOUD_COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
  82. if not PLAYLISTS_FILE.exists():
  83. PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
  84. def safe_music_path(relative_path: str) -> Path:
  85. prefix, _, inner_path = relative_path.partition("/")
  86. roots = {key: root.resolve() for key, root, _ in MUSIC_ROOTS}
  87. if prefix in roots:
  88. candidate = (roots[prefix] / inner_path).resolve()
  89. if candidate != roots[prefix] and roots[prefix] not in candidate.parents:
  90. raise HTTPException(status_code=400, detail="Invalid path")
  91. return candidate
  92. candidate = (LOCAL_MUSIC_DIR / relative_path).resolve()
  93. local_root = LOCAL_MUSIC_DIR.resolve()
  94. if candidate != local_root and local_root not in candidate.parents:
  95. raise HTTPException(status_code=400, detail="Invalid path")
  96. return candidate
  97. def load_playlists() -> list[dict[str, Any]]:
  98. ensure_storage()
  99. return json.loads(PLAYLISTS_FILE.read_text(encoding="utf-8"))
  100. def save_playlists(playlists: list[dict[str, Any]]) -> None:
  101. PLAYLISTS_FILE.write_text(
  102. json.dumps(playlists, ensure_ascii=False, indent=2), encoding="utf-8"
  103. )
  104. def is_under_root(path: Path, root: Path) -> bool:
  105. resolved_path = path.resolve()
  106. resolved_root = root.resolve()
  107. return resolved_path == resolved_root or resolved_root in resolved_path.parents
  108. def track_path_for(root: Path, path: Path, prefix: str = "") -> str:
  109. relative = path.relative_to(root).as_posix()
  110. return f"{prefix}/{relative}" if prefix else relative
  111. def track_path_from_abs(path: Path) -> str:
  112. if is_under_root(path, LOCAL_MUSIC_DIR):
  113. return path.relative_to(LOCAL_MUSIC_DIR).as_posix()
  114. if is_under_root(path, CLOUD_MUSIC_DIR):
  115. return f"cloud/{path.relative_to(CLOUD_MUSIC_DIR).as_posix()}"
  116. return path.name
  117. def path_is_under(path: Path, root: Path) -> bool:
  118. try:
  119. resolved_path = path.resolve()
  120. resolved_root = root.resolve()
  121. except OSError:
  122. return False
  123. return resolved_path == resolved_root or resolved_root in resolved_path.parents
  124. def trigger_cloud_cache_refresh_if_needed(*paths: Path) -> None:
  125. if any(path_is_under(path, CLOUD_MUSIC_DIR) for path in paths):
  126. start_cloud_library_refresh()
  127. def cloud_cover_cache_path(relative_path: str) -> Path:
  128. inner_path = relative_path.removeprefix("cloud/").lstrip("/")
  129. target = (CLOUD_COVER_CACHE_DIR / inner_path).resolve()
  130. cache_root = CLOUD_COVER_CACHE_DIR.resolve()
  131. if target != cache_root and cache_root not in target.parents:
  132. raise HTTPException(status_code=400, detail="Invalid cover path")
  133. return target
  134. def cloud_webdav_url(relative_path: str) -> str:
  135. inner_path = relative_path.removeprefix("cloud/").lstrip("/")
  136. encoded = "/".join(urllib.parse.quote(part) for part in inner_path.split("/") if part)
  137. base = CLOUD_WEBDAV_BASE.rstrip("/")
  138. return f"{base}/{encoded}" if encoded else base
  139. def cloud_alist_api_path(relative_path: str) -> str:
  140. inner_path = relative_path.removeprefix("cloud/").lstrip("/")
  141. return f"/百度网盘/mp3file/{inner_path}" if inner_path else "/百度网盘/mp3file"
  142. def _alist_list_dir(alist_path: str, token: str) -> list[dict[str, Any]]:
  143. """List files in an alist directory, handling pagination."""
  144. items: list[dict[str, Any]] = []
  145. page = 1
  146. while True:
  147. payload = json.dumps({"path": alist_path, "password": "", "page": page, "per_page": 200}).encode("utf-8")
  148. request = urllib.request.Request(
  149. f"{CLOUD_ALIST_BASE.rstrip('/')}/api/fs/list",
  150. data=payload,
  151. headers={
  152. "Content-Type": "application/json",
  153. "Authorization": token,
  154. "User-Agent": "MusicWebPlayer/1.0",
  155. },
  156. )
  157. try:
  158. with urllib.request.urlopen(request, timeout=20) as response:
  159. result = json.loads(response.read().decode("utf-8"))
  160. except urllib.error.HTTPError:
  161. break
  162. except urllib.error.URLError:
  163. break
  164. data = result.get("data")
  165. if not data or not isinstance(data, dict):
  166. break
  167. content_list = data.get("content", [])
  168. if not content_list:
  169. break
  170. items.extend(content_list)
  171. if not data.get("has_more"):
  172. break
  173. page += 1
  174. return items
  175. def _get_alist_token() -> str | None:
  176. """Get alist login token, cached briefly to avoid repeated logins."""
  177. now = datetime.now(timezone.utc).timestamp()
  178. cached = _ALIST_TOKEN_CACHE.get("token")
  179. if cached and cached[1] > now:
  180. return cached[0]
  181. payload = json.dumps(
  182. {"username": CLOUD_WEBDAV_USER, "password": CLOUD_WEBDAV_PASSWORD}
  183. ).encode("utf-8")
  184. request = urllib.request.Request(
  185. f"{CLOUD_ALIST_BASE.rstrip('/')}/api/auth/login",
  186. data=payload,
  187. headers={"Content-Type": "application/json", "User-Agent": "MusicWebPlayer/1.0"},
  188. )
  189. try:
  190. with urllib.request.urlopen(request, timeout=20) as response:
  191. result = json.loads(response.read().decode("utf-8"))
  192. except (urllib.error.HTTPError, urllib.error.URLError):
  193. return None
  194. token = result.get("data", {}).get("token")
  195. if token:
  196. _ALIST_TOKEN_CACHE["token"] = (token, now + 8000)
  197. return token
  198. def alist_login_token() -> str:
  199. payload = json.dumps(
  200. {
  201. "username": CLOUD_WEBDAV_USER,
  202. "password": CLOUD_WEBDAV_PASSWORD,
  203. }
  204. ).encode("utf-8")
  205. request = urllib.request.Request(
  206. f"{CLOUD_ALIST_BASE.rstrip('/')}/api/auth/login",
  207. data=payload,
  208. headers={"Content-Type": "application/json", "User-Agent": "MusicWebPlayer/1.0"},
  209. )
  210. try:
  211. with urllib.request.urlopen(request, timeout=20) as response:
  212. result = json.loads(response.read().decode("utf-8"))
  213. except urllib.error.HTTPError as error:
  214. raise HTTPException(status_code=error.code, detail=error.reason)
  215. except urllib.error.URLError as error:
  216. raise HTTPException(status_code=502, detail=str(error.reason))
  217. token = result.get("data", {}).get("token")
  218. if not token:
  219. raise HTTPException(status_code=502, detail="AList login failed")
  220. return token
  221. def cloud_raw_url(relative_path: str) -> str:
  222. payload = json.dumps({"path": cloud_alist_api_path(relative_path), "password": ""}).encode("utf-8")
  223. request = urllib.request.Request(
  224. f"{CLOUD_ALIST_BASE.rstrip('/')}/api/fs/get",
  225. data=payload,
  226. headers={
  227. "Content-Type": "application/json",
  228. "Authorization": alist_login_token(),
  229. "User-Agent": "MusicWebPlayer/1.0",
  230. },
  231. )
  232. try:
  233. with urllib.request.urlopen(request, timeout=20) as response:
  234. result = json.loads(response.read().decode("utf-8"))
  235. except urllib.error.HTTPError as error:
  236. raise HTTPException(status_code=error.code, detail=error.reason)
  237. except urllib.error.URLError as error:
  238. raise HTTPException(status_code=502, detail=str(error.reason))
  239. data = result.get("data", {})
  240. sign = data.get("sign")
  241. path = data.get("path")
  242. if not sign or not path:
  243. raise HTTPException(status_code=404, detail="Cloud signed url not found")
  244. encoded_path = "/".join(urllib.parse.quote(part) for part in path.lstrip("/").split("/"))
  245. quoted_sign = urllib.parse.quote(sign, safe="")
  246. return f"{CLOUD_ALIST_BASE.rstrip('/')}/d/{urllib.parse.quote('百度网盘')}/{encoded_path}?sign={quoted_sign}"
  247. def is_supported_file(path: Path) -> bool:
  248. return path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
  249. def track_url(relative_path: str) -> str:
  250. return f"/api/stream/{relative_path}"
  251. def build_track(relative_path: str) -> dict[str, str]:
  252. filename = Path(relative_path).name
  253. return {
  254. "id": relative_path,
  255. "name": filename,
  256. "path": relative_path,
  257. "url": track_url(relative_path),
  258. "folder": str(Path(relative_path).parent).replace(".", "").strip("/"),
  259. }
  260. def find_cover_for_directory(root: Path, prefix: str = "") -> str | None:
  261. path = root / "cover.jpg"
  262. if path.is_file():
  263. return track_path_for(root, path, prefix)
  264. return None
  265. def sanitize_cover_value(cover: Any) -> str | None:
  266. if not isinstance(cover, str) or not cover:
  267. return None
  268. parsed = urllib.parse.urlparse(cover)
  269. path = parsed.path or cover
  270. return cover if path.lower().endswith("/cover.jpg") else None
  271. def sanitize_library_tree(node: dict[str, Any]) -> dict[str, Any]:
  272. node["cover"] = sanitize_cover_value(node.get("cover"))
  273. for child in node.get("folders", []):
  274. if isinstance(child, dict):
  275. sanitize_library_tree(child)
  276. return node
  277. def build_tree(root: Path, prefix: str = "", label: str = "音乐库") -> dict[str, Any]:
  278. cover_path = find_cover_for_directory(root, prefix)
  279. node = {
  280. "name": label,
  281. "path": prefix,
  282. "cover": f"/api/cover/{cover_path}" if cover_path else None,
  283. "folders": [],
  284. "tracks": [],
  285. }
  286. for child in sorted(root.iterdir(), key=lambda item: (item.is_file(), _nat_sort_key(item.name))):
  287. if child.is_dir():
  288. child_rel = child.relative_to(root).as_posix()
  289. child_prefix = f"{prefix}/{child_rel}" if prefix else child_rel
  290. node["folders"].append(build_tree(child, child_prefix, child.name))
  291. elif is_supported_file(child):
  292. node["tracks"].append(build_track(track_path_for(root, child, prefix)))
  293. return node
  294. def collect_tracks(root: Path, prefix: str = "") -> list[dict[str, str]]:
  295. tracks: list[dict[str, str]] = []
  296. for path in sorted(root.rglob("*")):
  297. if is_supported_file(path):
  298. tracks.append(build_track(track_path_for(root, path, prefix)))
  299. return tracks
  300. def _build_cloud_tree_from_alist(alist_path: str, prefix: str, label: str, token: str) -> dict[str, Any]:
  301. """Build a library tree node by querying the alist API recursively."""
  302. node = {
  303. "name": label,
  304. "path": prefix,
  305. "cover": None,
  306. "folders": [],
  307. "tracks": [],
  308. }
  309. try:
  310. items = _alist_list_dir(alist_path, token)
  311. except Exception:
  312. return node
  313. folders: list[dict[str, Any]] = []
  314. tracks: list[dict[str, str]] = []
  315. for item in sorted(items, key=lambda i: (i.get("is_dir", 0), _nat_sort_key(i.get("name", "")))):
  316. name = item.get("name", "")
  317. is_dir = bool(item.get("is_dir"))
  318. if is_dir:
  319. child_prefix = f"{prefix}/{name}" if prefix else name
  320. child_path = f"{alist_path.rstrip('/')}/{name}" if alist_path else name
  321. folders.append(_build_cloud_tree_from_alist(child_path, child_prefix, name, token))
  322. else:
  323. track_path = f"{prefix}/{name}" if prefix else name
  324. tracks.append(build_track(track_path))
  325. node["folders"] = folders
  326. node["tracks"] = tracks
  327. return node
  328. def root_entry(source: str) -> tuple[str, Path, str]:
  329. for prefix, root, label in MUSIC_ROOTS:
  330. if prefix == source:
  331. return prefix, root, label
  332. raise HTTPException(status_code=404, detail="Library source not found")
  333. def current_timestamp() -> str:
  334. return datetime.now(timezone.utc).isoformat()
  335. def build_library_payload(source: str | None = None) -> dict[str, Any]:
  336. ensure_storage()
  337. entries = MUSIC_ROOTS if source is None else [root_entry(source)]
  338. tree = {
  339. "name": "音乐库",
  340. "path": "",
  341. "cover": None,
  342. "folders": [
  343. build_tree(root, prefix, label)
  344. for prefix, root, label in entries
  345. if root.exists()
  346. ],
  347. "tracks": [],
  348. }
  349. all_tracks: list[dict[str, str]] = []
  350. for prefix, root, _ in entries:
  351. if root.exists():
  352. all_tracks.extend(collect_tracks(root, prefix))
  353. sanitize_library_tree(tree)
  354. return {
  355. "tree": tree,
  356. "all_tracks": all_tracks,
  357. "playlists": load_playlists(),
  358. }
  359. def read_cloud_library_cache() -> dict[str, Any] | None:
  360. if not CLOUD_LIBRARY_CACHE_FILE.exists():
  361. return None
  362. try:
  363. payload = json.loads(CLOUD_LIBRARY_CACHE_FILE.read_text(encoding="utf-8"))
  364. except (OSError, json.JSONDecodeError):
  365. return None
  366. if not isinstance(payload, dict):
  367. return None
  368. tree = payload.get("tree")
  369. if isinstance(tree, dict):
  370. sanitize_library_tree(tree)
  371. return payload
  372. def write_cloud_library_cache(payload: dict[str, Any]) -> dict[str, Any]:
  373. cached_payload = {
  374. **payload,
  375. "cache": {
  376. "updated_at": current_timestamp(),
  377. "is_cached": True,
  378. "refreshing": False,
  379. },
  380. }
  381. CLOUD_LIBRARY_CACHE_FILE.write_text(
  382. json.dumps(cached_payload, ensure_ascii=False, indent=2),
  383. encoding="utf-8",
  384. )
  385. return cached_payload
  386. def build_cloud_library_payload() -> dict[str, Any]:
  387. ensure_storage()
  388. token = _get_alist_token()
  389. if not token:
  390. return {
  391. "tree": {"name": "音乐库", "path": "", "cover": None, "folders": [], "tracks": []},
  392. "all_tracks": [],
  393. "playlists": load_playlists(),
  394. }
  395. try:
  396. cloud_tree = _build_cloud_tree_from_alist("/百度网盘/mp3file", "cloud", "百度网盘", token)
  397. except Exception:
  398. cloud_tree = {"name": "百度网盘", "path": "cloud", "cover": None, "folders": [], "tracks": []}
  399. all_tracks: list[dict[str, str]] = []
  400. def _collect(node: dict[str, Any]) -> None:
  401. all_tracks.extend(node.get("tracks", []))
  402. for child in node.get("folders", []):
  403. _collect(child)
  404. _collect(cloud_tree)
  405. sanitize_library_tree(cloud_tree)
  406. return {
  407. "tree": {
  408. "name": "音乐库",
  409. "path": "",
  410. "cover": None,
  411. "folders": [cloud_tree],
  412. "tracks": [],
  413. },
  414. "all_tracks": all_tracks,
  415. "playlists": load_playlists(),
  416. }
  417. def cloud_library_payload_from_cache() -> dict[str, Any] | None:
  418. payload = read_cloud_library_cache()
  419. if not payload:
  420. return None
  421. cache_meta = payload.get("cache") if isinstance(payload.get("cache"), dict) else {}
  422. payload["cache"] = {
  423. "updated_at": cache_meta.get("updated_at"),
  424. "is_cached": True,
  425. "refreshing": CLOUD_REFRESH_STATE["running"],
  426. }
  427. return payload
  428. def refresh_cloud_library_cache_sync() -> dict[str, Any]:
  429. payload = build_cloud_library_payload()
  430. return write_cloud_library_cache(payload)
  431. def start_cloud_library_refresh() -> bool:
  432. with CLOUD_REFRESH_LOCK:
  433. if CLOUD_REFRESH_STATE["running"]:
  434. return False
  435. CLOUD_REFRESH_STATE["running"] = True
  436. def runner() -> None:
  437. try:
  438. refresh_cloud_library_cache_sync()
  439. finally:
  440. with CLOUD_REFRESH_LOCK:
  441. CLOUD_REFRESH_STATE["running"] = False
  442. threading.Thread(target=runner, daemon=True).start()
  443. return True
  444. @app.on_event("startup")
  445. def startup_event() -> None:
  446. ensure_storage()
  447. if not CLOUD_LIBRARY_CACHE_FILE.exists():
  448. start_cloud_library_refresh()
  449. @app.get("/", response_class=HTMLResponse)
  450. def index(request: Request) -> HTMLResponse:
  451. return templates.TemplateResponse("index.html", {"request": request})
  452. @app.get("/api/library")
  453. def library() -> JSONResponse:
  454. return JSONResponse(build_library_payload())
  455. @app.get("/api/library/{source}")
  456. def library_by_source(source: str) -> JSONResponse:
  457. normalized = "" if source == "local" else source
  458. if normalized == "cloud":
  459. cached = cloud_library_payload_from_cache()
  460. if cached:
  461. start_cloud_library_refresh()
  462. return JSONResponse(cached)
  463. payload = refresh_cloud_library_cache_sync()
  464. return JSONResponse(payload)
  465. return JSONResponse(build_library_payload(normalized))
  466. @app.get("/api/library/{source}/refresh")
  467. def refresh_library_by_source(source: str) -> JSONResponse:
  468. normalized = "" if source == "local" else source
  469. if normalized != "cloud":
  470. return JSONResponse(build_library_payload(normalized))
  471. started = start_cloud_library_refresh()
  472. cached = cloud_library_payload_from_cache()
  473. if cached:
  474. cached["cache"]["refreshing"] = True
  475. return JSONResponse({"started": started, "library": cached})
  476. payload = refresh_cloud_library_cache_sync()
  477. return JSONResponse({"started": started, "library": payload})
  478. @app.get("/api/stream/{file_path:path}")
  479. def stream_file(request: Request, file_path: str) -> StreamingResponse:
  480. if file_path.startswith("cloud/"):
  481. return proxy_cloud_stream(cloud_raw_url(file_path), request.headers.get("range"))
  482. file = safe_music_path(file_path)
  483. if not file.exists() or not file.is_file():
  484. raise HTTPException(status_code=404, detail="File not found")
  485. media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
  486. def file_iterator() -> Any:
  487. with file.open("rb") as handle:
  488. while chunk := handle.read(1024 * 1024):
  489. if not chunk:
  490. break
  491. yield chunk
  492. return StreamingResponse(
  493. file_iterator(),
  494. media_type=media_type,
  495. headers={"Cache-Control": "private, no-cache"},
  496. )
  497. @app.get("/api/cloud-url/{file_path:path}")
  498. def get_cloud_url(file_path: str) -> JSONResponse:
  499. if not file_path.startswith("cloud/"):
  500. raise HTTPException(status_code=400, detail="Not a cloud track")
  501. return JSONResponse({"url": cloud_raw_url(file_path)})
  502. def proxy_cloud_stream(remote_url: str, range_header: str | None = None) -> StreamingResponse:
  503. auth = base64.b64encode(f"{CLOUD_WEBDAV_USER}:{CLOUD_WEBDAV_PASSWORD}".encode("utf-8")).decode("ascii")
  504. headers = {
  505. "Authorization": f"Basic {auth}",
  506. "User-Agent": "MusicWebPlayer/1.0",
  507. }
  508. if range_header:
  509. headers["Range"] = range_header
  510. request = urllib.request.Request(
  511. remote_url,
  512. headers=headers,
  513. )
  514. try:
  515. response = urllib.request.urlopen(request, timeout=30)
  516. except urllib.error.HTTPError as error:
  517. raise HTTPException(status_code=error.code, detail=error.reason)
  518. except urllib.error.URLError as error:
  519. raise HTTPException(status_code=502, detail=str(error.reason))
  520. media_type = mimetypes.guess_type(urllib.parse.urlparse(remote_url).path)[0] or response.headers.get_content_type() or "audio/mpeg"
  521. passthrough_headers = {
  522. "Cache-Control": "private, no-cache",
  523. "Accept-Ranges": response.headers.get("Accept-Ranges") or "bytes",
  524. "Content-Type": media_type,
  525. }
  526. for key in ("ETag", "Last-Modified", "Content-Range"):
  527. value = response.headers.get(key)
  528. if value:
  529. passthrough_headers[key] = value
  530. def remote_iterator() -> Any:
  531. with response:
  532. while chunk := response.read(1024 * 1024):
  533. yield chunk
  534. return StreamingResponse(
  535. remote_iterator(),
  536. status_code=getattr(response, "status", 200),
  537. media_type=media_type,
  538. headers=passthrough_headers,
  539. )
  540. def cache_cloud_cover(relative_path: str) -> Path:
  541. cached_file = cloud_cover_cache_path(relative_path)
  542. if cached_file.exists() and cached_file.is_file():
  543. return cached_file
  544. cached_file.parent.mkdir(parents=True, exist_ok=True)
  545. remote_url = cloud_raw_url(relative_path)
  546. request = urllib.request.Request(
  547. remote_url,
  548. headers={"User-Agent": "MusicWebPlayer/1.0"},
  549. )
  550. try:
  551. with urllib.request.urlopen(request, timeout=30) as response:
  552. with cached_file.open("wb") as output:
  553. shutil.copyfileobj(response, output)
  554. except urllib.error.HTTPError as error:
  555. raise HTTPException(status_code=error.code, detail=error.reason)
  556. except urllib.error.URLError as error:
  557. raise HTTPException(status_code=502, detail=str(error.reason))
  558. except OSError as error:
  559. raise HTTPException(status_code=500, detail=str(error))
  560. return cached_file
  561. @app.get("/api/cover/{file_path:path}")
  562. def cover_file(request: Request, file_path: str):
  563. if file_path.startswith("cloud/"):
  564. if Path(file_path).name.lower() != "cover.jpg":
  565. raise HTTPException(status_code=404, detail="Cover not found")
  566. cached_file = cache_cloud_cover(file_path)
  567. media_type = mimetypes.guess_type(cached_file.name)[0] or "image/jpeg"
  568. return FileResponse(cached_file, media_type=media_type, filename=cached_file.name)
  569. file = safe_music_path(file_path)
  570. if file.name.lower() != "cover.jpg" or not file.exists() or not file.is_file():
  571. raise HTTPException(status_code=404, detail="Cover not found")
  572. media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
  573. return FileResponse(file, media_type=media_type, filename=file.name)
  574. @app.post("/api/upload")
  575. async def upload_files(
  576. files: list[UploadFile] = File(...),
  577. target_dir: str = Form(default=""),
  578. ) -> JSONResponse:
  579. destination = safe_music_path(target_dir)
  580. if not str(destination).startswith(str(LOCAL_MUSIC_DIR.resolve())):
  581. raise HTTPException(status_code=400, detail="Upload destination must be local music dir")
  582. destination.mkdir(parents=True, exist_ok=True)
  583. saved: list[str] = []
  584. for upload in files:
  585. suffix = Path(upload.filename or "").suffix.lower()
  586. if suffix not in SUPPORTED_EXTENSIONS:
  587. continue
  588. filename = Path(upload.filename or f"upload-{uuid.uuid4().hex}").name
  589. file_path = destination / filename
  590. with file_path.open("wb") as buffer:
  591. shutil.copyfileobj(upload.file, buffer)
  592. saved.append(track_path_from_abs(file_path))
  593. trigger_cloud_cache_refresh_if_needed(destination)
  594. return JSONResponse({"saved": saved})
  595. @app.post("/api/folder")
  596. def create_folder(payload: FolderCreateRequest) -> JSONResponse:
  597. folder = safe_music_path(payload.path)
  598. if not str(folder).startswith(str(LOCAL_MUSIC_DIR.resolve())):
  599. raise HTTPException(status_code=400, detail="Folder must be created in local music dir")
  600. folder.mkdir(parents=True, exist_ok=True)
  601. trigger_cloud_cache_refresh_if_needed(folder)
  602. return JSONResponse({"created": track_path_from_abs(folder)})
  603. @app.post("/api/move")
  604. def move_file(payload: MoveRequest) -> JSONResponse:
  605. source = safe_music_path(payload.source)
  606. destination_dir = safe_music_path(payload.destination_dir)
  607. if not source.exists():
  608. raise HTTPException(status_code=404, detail="Source not found")
  609. if not destination_dir.exists():
  610. destination_dir.mkdir(parents=True, exist_ok=True)
  611. destination = destination_dir / source.name
  612. shutil.move(str(source), str(destination))
  613. trigger_cloud_cache_refresh_if_needed(source, destination_dir, destination)
  614. return JSONResponse({"moved": track_path_from_abs(destination)})
  615. @app.post("/api/playlists")
  616. def create_playlist(payload: PlaylistCreateRequest) -> JSONResponse:
  617. playlists = load_playlists()
  618. playlist = {
  619. "id": uuid.uuid4().hex,
  620. "name": payload.name.strip() or "未命名播放列表",
  621. "tracks": payload.tracks,
  622. }
  623. playlists.append(playlist)
  624. save_playlists(playlists)
  625. return JSONResponse(playlist)
  626. @app.put("/api/playlists/{playlist_id}")
  627. def update_playlist(playlist_id: str, payload: PlaylistUpdateRequest) -> JSONResponse:
  628. playlists = load_playlists()
  629. for playlist in playlists:
  630. if playlist["id"] == playlist_id:
  631. playlist["tracks"] = payload.tracks
  632. save_playlists(playlists)
  633. return JSONResponse(playlist)
  634. raise HTTPException(status_code=404, detail="Playlist not found")
  635. @app.delete("/api/playlists/{playlist_id}")
  636. def delete_playlist(playlist_id: str) -> JSONResponse:
  637. playlists = load_playlists()
  638. filtered = [playlist for playlist in playlists if playlist["id"] != playlist_id]
  639. if len(filtered) == len(playlists):
  640. raise HTTPException(status_code=404, detail="Playlist not found")
  641. save_playlists(filtered)
  642. return JSONResponse({"deleted": playlist_id})