|
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|
|
import json
|
|
import json
|
|
|
import mimetypes
|
|
import mimetypes
|
|
|
import shutil
|
|
import shutil
|
|
|
|
|
+import threading
|
|
|
import uuid
|
|
import uuid
|
|
|
import base64
|
|
import base64
|
|
|
import urllib.error
|
|
import urllib.error
|
|
|
import urllib.parse
|
|
import urllib.parse
|
|
|
import urllib.request
|
|
import urllib.request
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
from typing import Any
|
|
|
|
|
|
|
@@ -29,6 +31,9 @@ MUSIC_ROOTS: list[tuple[str, Path, str]] = [
|
|
|
("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
|
|
("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
|
|
|
]
|
|
]
|
|
|
PLAYLISTS_FILE = BASE_DIR / "playlists.json"
|
|
PLAYLISTS_FILE = BASE_DIR / "playlists.json"
|
|
|
|
|
+CACHE_DIR = BASE_DIR / ".cache"
|
|
|
|
|
+CLOUD_LIBRARY_CACHE_FILE = CACHE_DIR / "cloud_library.json"
|
|
|
|
|
+CLOUD_COVER_CACHE_DIR = CACHE_DIR / "cloud_covers"
|
|
|
SUPPORTED_EXTENSIONS = {
|
|
SUPPORTED_EXTENSIONS = {
|
|
|
".mp3",
|
|
".mp3",
|
|
|
".wav",
|
|
".wav",
|
|
@@ -49,6 +54,8 @@ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
|
|
app = FastAPI(title="MusicWeb", version="1.0.0")
|
|
app = FastAPI(title="MusicWeb", version="1.0.0")
|
|
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
|
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
|
|
|
+CLOUD_REFRESH_STATE = {"running": False}
|
|
|
|
|
+CLOUD_REFRESH_LOCK = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
class FolderCreateRequest(BaseModel):
|
|
class FolderCreateRequest(BaseModel):
|
|
@@ -72,6 +79,8 @@ class PlaylistUpdateRequest(BaseModel):
|
|
|
def ensure_storage() -> None:
|
|
def ensure_storage() -> None:
|
|
|
LOCAL_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
LOCAL_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
CLOUD_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
CLOUD_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ CLOUD_COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
if not PLAYLISTS_FILE.exists():
|
|
if not PLAYLISTS_FILE.exists():
|
|
|
PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
|
|
PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
|
|
|
|
|
|
|
@@ -122,6 +131,29 @@ def track_path_from_abs(path: Path) -> str:
|
|
|
return path.name
|
|
return path.name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def path_is_under(path: Path, root: Path) -> bool:
|
|
|
|
|
+ try:
|
|
|
|
|
+ resolved_path = path.resolve()
|
|
|
|
|
+ resolved_root = root.resolve()
|
|
|
|
|
+ except OSError:
|
|
|
|
|
+ return False
|
|
|
|
|
+ return resolved_path == resolved_root or resolved_root in resolved_path.parents
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def trigger_cloud_cache_refresh_if_needed(*paths: Path) -> None:
|
|
|
|
|
+ if any(path_is_under(path, CLOUD_MUSIC_DIR) for path in paths):
|
|
|
|
|
+ start_cloud_library_refresh()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def cloud_cover_cache_path(relative_path: str) -> Path:
|
|
|
|
|
+ inner_path = relative_path.removeprefix("cloud/").lstrip("/")
|
|
|
|
|
+ target = (CLOUD_COVER_CACHE_DIR / inner_path).resolve()
|
|
|
|
|
+ cache_root = CLOUD_COVER_CACHE_DIR.resolve()
|
|
|
|
|
+ if target != cache_root and cache_root not in target.parents:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Invalid cover path")
|
|
|
|
|
+ return target
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def cloud_webdav_url(relative_path: str) -> str:
|
|
def cloud_webdav_url(relative_path: str) -> str:
|
|
|
inner_path = relative_path.removeprefix("cloud/").lstrip("/")
|
|
inner_path = relative_path.removeprefix("cloud/").lstrip("/")
|
|
|
encoded = "/".join(urllib.parse.quote(part) for part in inner_path.split("/") if part)
|
|
encoded = "/".join(urllib.parse.quote(part) for part in inner_path.split("/") if part)
|
|
@@ -209,27 +241,28 @@ def build_track(relative_path: str) -> dict[str, str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_cover_for_directory(root: Path, prefix: str = "") -> str | None:
|
|
def find_cover_for_directory(root: Path, prefix: str = "") -> str | None:
|
|
|
- candidates = (
|
|
|
|
|
- "cover.jpg",
|
|
|
|
|
- "cover.jpeg",
|
|
|
|
|
- "cover.png",
|
|
|
|
|
- "cover.webp",
|
|
|
|
|
- "folder.jpg",
|
|
|
|
|
- "folder.jpeg",
|
|
|
|
|
- "folder.png",
|
|
|
|
|
- "folder.webp",
|
|
|
|
|
- )
|
|
|
|
|
- for candidate in candidates:
|
|
|
|
|
- path = root / candidate
|
|
|
|
|
- if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
|
|
|
|
|
- return track_path_for(root, path, prefix)
|
|
|
|
|
-
|
|
|
|
|
- for child in sorted(root.iterdir(), key=lambda item: item.name.lower()):
|
|
|
|
|
- if child.is_file() and child.suffix.lower() in IMAGE_EXTENSIONS:
|
|
|
|
|
- return track_path_for(root, child, prefix)
|
|
|
|
|
|
|
+ path = root / "cover.jpg"
|
|
|
|
|
+ if path.is_file():
|
|
|
|
|
+ return track_path_for(root, path, prefix)
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def sanitize_cover_value(cover: Any) -> str | None:
|
|
|
|
|
+ if not isinstance(cover, str) or not cover:
|
|
|
|
|
+ return None
|
|
|
|
|
+ parsed = urllib.parse.urlparse(cover)
|
|
|
|
|
+ path = parsed.path or cover
|
|
|
|
|
+ return cover if path.lower().endswith("/cover.jpg") else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def sanitize_library_tree(node: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ node["cover"] = sanitize_cover_value(node.get("cover"))
|
|
|
|
|
+ for child in node.get("folders", []):
|
|
|
|
|
+ if isinstance(child, dict):
|
|
|
|
|
+ sanitize_library_tree(child)
|
|
|
|
|
+ return node
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def build_tree(root: Path, prefix: str = "", label: str = "音乐库") -> dict[str, Any]:
|
|
def build_tree(root: Path, prefix: str = "", label: str = "音乐库") -> dict[str, Any]:
|
|
|
cover_path = find_cover_for_directory(root, prefix)
|
|
cover_path = find_cover_for_directory(root, prefix)
|
|
|
node = {
|
|
node = {
|
|
@@ -264,6 +297,10 @@ def root_entry(source: str) -> tuple[str, Path, str]:
|
|
|
raise HTTPException(status_code=404, detail="Library source not found")
|
|
raise HTTPException(status_code=404, detail="Library source not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def current_timestamp() -> str:
|
|
|
|
|
+ return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def build_library_payload(source: str | None = None) -> dict[str, Any]:
|
|
def build_library_payload(source: str | None = None) -> dict[str, Any]:
|
|
|
ensure_storage()
|
|
ensure_storage()
|
|
|
entries = MUSIC_ROOTS if source is None else [root_entry(source)]
|
|
entries = MUSIC_ROOTS if source is None else [root_entry(source)]
|
|
@@ -282,6 +319,7 @@ def build_library_payload(source: str | None = None) -> dict[str, Any]:
|
|
|
for prefix, root, _ in entries:
|
|
for prefix, root, _ in entries:
|
|
|
if root.exists():
|
|
if root.exists():
|
|
|
all_tracks.extend(collect_tracks(root, prefix))
|
|
all_tracks.extend(collect_tracks(root, prefix))
|
|
|
|
|
+ sanitize_library_tree(tree)
|
|
|
return {
|
|
return {
|
|
|
"tree": tree,
|
|
"tree": tree,
|
|
|
"all_tracks": all_tracks,
|
|
"all_tracks": all_tracks,
|
|
@@ -289,9 +327,81 @@ def build_library_payload(source: str | None = None) -> dict[str, Any]:
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def read_cloud_library_cache() -> dict[str, Any] | None:
|
|
|
|
|
+ if not CLOUD_LIBRARY_CACHE_FILE.exists():
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ payload = json.loads(CLOUD_LIBRARY_CACHE_FILE.read_text(encoding="utf-8"))
|
|
|
|
|
+ except (OSError, json.JSONDecodeError):
|
|
|
|
|
+ return None
|
|
|
|
|
+ if not isinstance(payload, dict):
|
|
|
|
|
+ return None
|
|
|
|
|
+ tree = payload.get("tree")
|
|
|
|
|
+ if isinstance(tree, dict):
|
|
|
|
|
+ sanitize_library_tree(tree)
|
|
|
|
|
+ return payload
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def write_cloud_library_cache(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
+ cached_payload = {
|
|
|
|
|
+ **payload,
|
|
|
|
|
+ "cache": {
|
|
|
|
|
+ "updated_at": current_timestamp(),
|
|
|
|
|
+ "is_cached": True,
|
|
|
|
|
+ "refreshing": False,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ CLOUD_LIBRARY_CACHE_FILE.write_text(
|
|
|
|
|
+ json.dumps(cached_payload, ensure_ascii=False, indent=2),
|
|
|
|
|
+ encoding="utf-8",
|
|
|
|
|
+ )
|
|
|
|
|
+ return cached_payload
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_cloud_library_payload() -> dict[str, Any]:
|
|
|
|
|
+ return build_library_payload("cloud")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def cloud_library_payload_from_cache() -> dict[str, Any] | None:
|
|
|
|
|
+ payload = read_cloud_library_cache()
|
|
|
|
|
+ if not payload:
|
|
|
|
|
+ return None
|
|
|
|
|
+ cache_meta = payload.get("cache") if isinstance(payload.get("cache"), dict) else {}
|
|
|
|
|
+ payload["cache"] = {
|
|
|
|
|
+ "updated_at": cache_meta.get("updated_at"),
|
|
|
|
|
+ "is_cached": True,
|
|
|
|
|
+ "refreshing": CLOUD_REFRESH_STATE["running"],
|
|
|
|
|
+ }
|
|
|
|
|
+ return payload
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def refresh_cloud_library_cache_sync() -> dict[str, Any]:
|
|
|
|
|
+ payload = build_cloud_library_payload()
|
|
|
|
|
+ return write_cloud_library_cache(payload)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def start_cloud_library_refresh() -> bool:
|
|
|
|
|
+ with CLOUD_REFRESH_LOCK:
|
|
|
|
|
+ if CLOUD_REFRESH_STATE["running"]:
|
|
|
|
|
+ return False
|
|
|
|
|
+ CLOUD_REFRESH_STATE["running"] = True
|
|
|
|
|
+
|
|
|
|
|
+ def runner() -> None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ refresh_cloud_library_cache_sync()
|
|
|
|
|
+ finally:
|
|
|
|
|
+ with CLOUD_REFRESH_LOCK:
|
|
|
|
|
+ CLOUD_REFRESH_STATE["running"] = False
|
|
|
|
|
+
|
|
|
|
|
+ threading.Thread(target=runner, daemon=True).start()
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@app.on_event("startup")
|
|
@app.on_event("startup")
|
|
|
def startup_event() -> None:
|
|
def startup_event() -> None:
|
|
|
ensure_storage()
|
|
ensure_storage()
|
|
|
|
|
+ if not CLOUD_LIBRARY_CACHE_FILE.exists():
|
|
|
|
|
+ start_cloud_library_refresh()
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
@app.get("/", response_class=HTMLResponse)
|
|
@@ -307,9 +417,33 @@ def library() -> JSONResponse:
|
|
|
@app.get("/api/library/{source}")
|
|
@app.get("/api/library/{source}")
|
|
|
def library_by_source(source: str) -> JSONResponse:
|
|
def library_by_source(source: str) -> JSONResponse:
|
|
|
normalized = "" if source == "local" else source
|
|
normalized = "" if source == "local" else source
|
|
|
|
|
+ if normalized == "cloud":
|
|
|
|
|
+ cached = cloud_library_payload_from_cache()
|
|
|
|
|
+ if cached:
|
|
|
|
|
+ start_cloud_library_refresh()
|
|
|
|
|
+ return JSONResponse(cached)
|
|
|
|
|
+
|
|
|
|
|
+ payload = refresh_cloud_library_cache_sync()
|
|
|
|
|
+ return JSONResponse(payload)
|
|
|
return JSONResponse(build_library_payload(normalized))
|
|
return JSONResponse(build_library_payload(normalized))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+@app.get("/api/library/{source}/refresh")
|
|
|
|
|
+def refresh_library_by_source(source: str) -> JSONResponse:
|
|
|
|
|
+ normalized = "" if source == "local" else source
|
|
|
|
|
+ if normalized != "cloud":
|
|
|
|
|
+ return JSONResponse(build_library_payload(normalized))
|
|
|
|
|
+
|
|
|
|
|
+ started = start_cloud_library_refresh()
|
|
|
|
|
+ cached = cloud_library_payload_from_cache()
|
|
|
|
|
+ if cached:
|
|
|
|
|
+ cached["cache"]["refreshing"] = True
|
|
|
|
|
+ return JSONResponse({"started": started, "library": cached})
|
|
|
|
|
+
|
|
|
|
|
+ payload = refresh_cloud_library_cache_sync()
|
|
|
|
|
+ return JSONResponse({"started": started, "library": payload})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@app.get("/api/stream/{file_path:path}")
|
|
@app.get("/api/stream/{file_path:path}")
|
|
|
def stream_file(request: Request, file_path: str) -> StreamingResponse:
|
|
def stream_file(request: Request, file_path: str) -> StreamingResponse:
|
|
|
if file_path.startswith("cloud/"):
|
|
if file_path.startswith("cloud/"):
|
|
@@ -384,15 +518,42 @@ def proxy_cloud_stream(remote_url: str, range_header: str | None = None) -> Stre
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def cache_cloud_cover(relative_path: str) -> Path:
|
|
|
|
|
+ cached_file = cloud_cover_cache_path(relative_path)
|
|
|
|
|
+ if cached_file.exists() and cached_file.is_file():
|
|
|
|
|
+ return cached_file
|
|
|
|
|
+
|
|
|
|
|
+ cached_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ remote_url = cloud_raw_url(relative_path)
|
|
|
|
|
+ request = urllib.request.Request(
|
|
|
|
|
+ remote_url,
|
|
|
|
|
+ headers={"User-Agent": "MusicWebPlayer/1.0"},
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ with urllib.request.urlopen(request, timeout=30) as response:
|
|
|
|
|
+ with cached_file.open("wb") as output:
|
|
|
|
|
+ shutil.copyfileobj(response, output)
|
|
|
|
|
+ except urllib.error.HTTPError as error:
|
|
|
|
|
+ raise HTTPException(status_code=error.code, detail=error.reason)
|
|
|
|
|
+ except urllib.error.URLError as error:
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=str(error.reason))
|
|
|
|
|
+ except OSError as error:
|
|
|
|
|
+ raise HTTPException(status_code=500, detail=str(error))
|
|
|
|
|
+
|
|
|
|
|
+ return cached_file
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@app.get("/api/cover/{file_path:path}")
|
|
@app.get("/api/cover/{file_path:path}")
|
|
|
def cover_file(request: Request, file_path: str):
|
|
def cover_file(request: Request, file_path: str):
|
|
|
if file_path.startswith("cloud/"):
|
|
if file_path.startswith("cloud/"):
|
|
|
- if Path(file_path).suffix.lower() not in IMAGE_EXTENSIONS:
|
|
|
|
|
|
|
+ if Path(file_path).name.lower() != "cover.jpg":
|
|
|
raise HTTPException(status_code=404, detail="Cover not found")
|
|
raise HTTPException(status_code=404, detail="Cover not found")
|
|
|
- return proxy_cloud_stream(cloud_raw_url(file_path), request.headers.get("range"))
|
|
|
|
|
|
|
+ cached_file = cache_cloud_cover(file_path)
|
|
|
|
|
+ media_type = mimetypes.guess_type(cached_file.name)[0] or "image/jpeg"
|
|
|
|
|
+ return FileResponse(cached_file, media_type=media_type, filename=cached_file.name)
|
|
|
|
|
|
|
|
file = safe_music_path(file_path)
|
|
file = safe_music_path(file_path)
|
|
|
- if not file.exists() or not file.is_file() or file.suffix.lower() not in IMAGE_EXTENSIONS:
|
|
|
|
|
|
|
+ if file.name.lower() != "cover.jpg" or not file.exists() or not file.is_file():
|
|
|
raise HTTPException(status_code=404, detail="Cover not found")
|
|
raise HTTPException(status_code=404, detail="Cover not found")
|
|
|
media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
|
media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
|
|
return FileResponse(file, media_type=media_type, filename=file.name)
|
|
return FileResponse(file, media_type=media_type, filename=file.name)
|
|
@@ -417,6 +578,7 @@ async def upload_files(
|
|
|
with file_path.open("wb") as buffer:
|
|
with file_path.open("wb") as buffer:
|
|
|
shutil.copyfileobj(upload.file, buffer)
|
|
shutil.copyfileobj(upload.file, buffer)
|
|
|
saved.append(track_path_from_abs(file_path))
|
|
saved.append(track_path_from_abs(file_path))
|
|
|
|
|
+ trigger_cloud_cache_refresh_if_needed(destination)
|
|
|
return JSONResponse({"saved": saved})
|
|
return JSONResponse({"saved": saved})
|
|
|
|
|
|
|
|
|
|
|
|
@@ -426,6 +588,7 @@ def create_folder(payload: FolderCreateRequest) -> JSONResponse:
|
|
|
if not str(folder).startswith(str(LOCAL_MUSIC_DIR.resolve())):
|
|
if not str(folder).startswith(str(LOCAL_MUSIC_DIR.resolve())):
|
|
|
raise HTTPException(status_code=400, detail="Folder must be created in local music dir")
|
|
raise HTTPException(status_code=400, detail="Folder must be created in local music dir")
|
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ trigger_cloud_cache_refresh_if_needed(folder)
|
|
|
return JSONResponse({"created": track_path_from_abs(folder)})
|
|
return JSONResponse({"created": track_path_from_abs(folder)})
|
|
|
|
|
|
|
|
|
|
|
|
@@ -439,6 +602,7 @@ def move_file(payload: MoveRequest) -> JSONResponse:
|
|
|
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
|
destination = destination_dir / source.name
|
|
destination = destination_dir / source.name
|
|
|
shutil.move(str(source), str(destination))
|
|
shutil.move(str(source), str(destination))
|
|
|
|
|
+ trigger_cloud_cache_refresh_if_needed(source, destination_dir, destination)
|
|
|
return JSONResponse({"moved": track_path_from_abs(destination)})
|
|
return JSONResponse({"moved": track_path_from_abs(destination)})
|
|
|
|
|
|
|
|
|
|
|