sequoia00 пре 2 недеља
родитељ
комит
95d5c2c81a
2 измењених фајлова са 261 додато и 24 уклоњено
  1. 185 21
      app/main.py
  2. 76 3
      static/js/app.js

+ 185 - 21
app/main.py

@@ -3,11 +3,13 @@ from __future__ import annotations
 import json
 import mimetypes
 import shutil
+import threading
 import uuid
 import base64
 import urllib.error
 import urllib.parse
 import urllib.request
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Any
 
@@ -29,6 +31,9 @@ MUSIC_ROOTS: list[tuple[str, Path, str]] = [
     ("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
 ]
 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 = {
     ".mp3",
     ".wav",
@@ -49,6 +54,8 @@ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
 app = FastAPI(title="MusicWeb", version="1.0.0")
 app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
 templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+CLOUD_REFRESH_STATE = {"running": False}
+CLOUD_REFRESH_LOCK = threading.Lock()
 
 
 class FolderCreateRequest(BaseModel):
@@ -72,6 +79,8 @@ class PlaylistUpdateRequest(BaseModel):
 def ensure_storage() -> None:
     LOCAL_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():
         PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
 
@@ -122,6 +131,29 @@ def track_path_from_abs(path: Path) -> str:
     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:
     inner_path = relative_path.removeprefix("cloud/").lstrip("/")
     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:
-    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
 
 
+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]:
     cover_path = find_cover_for_directory(root, prefix)
     node = {
@@ -264,6 +297,10 @@ def root_entry(source: str) -> tuple[str, Path, str]:
     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]:
     ensure_storage()
     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:
         if root.exists():
             all_tracks.extend(collect_tracks(root, prefix))
+    sanitize_library_tree(tree)
     return {
         "tree": tree,
         "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")
 def startup_event() -> None:
     ensure_storage()
+    if not CLOUD_LIBRARY_CACHE_FILE.exists():
+        start_cloud_library_refresh()
 
 
 @app.get("/", response_class=HTMLResponse)
@@ -307,9 +417,33 @@ def library() -> JSONResponse:
 @app.get("/api/library/{source}")
 def library_by_source(source: str) -> JSONResponse:
     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))
 
 
+@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}")
 def stream_file(request: Request, file_path: str) -> StreamingResponse:
     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}")
 def cover_file(request: Request, file_path: str):
     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")
-        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)
-    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")
     media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
     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:
             shutil.copyfileobj(upload.file, buffer)
         saved.append(track_path_from_abs(file_path))
+    trigger_cloud_cache_refresh_if_needed(destination)
     return JSONResponse({"saved": saved})
 
 
@@ -426,6 +588,7 @@ def create_folder(payload: FolderCreateRequest) -> JSONResponse:
     if not str(folder).startswith(str(LOCAL_MUSIC_DIR.resolve())):
         raise HTTPException(status_code=400, detail="Folder must be created in local music dir")
     folder.mkdir(parents=True, exist_ok=True)
+    trigger_cloud_cache_refresh_if_needed(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 = destination_dir / source.name
     shutil.move(str(source), str(destination))
+    trigger_cloud_cache_refresh_if_needed(source, destination_dir, destination)
     return JSONResponse({"moved": track_path_from_abs(destination)})
 
 

+ 76 - 3
static/js/app.js

@@ -21,6 +21,7 @@ const state = {
 const audio = document.getElementById("audioPlayer");
 const progressBar = document.getElementById("progressBar");
 const audioCacheName = "musicweb-audio-cache-v1";
+const cloudLibraryCacheKey = "musicweb-cloud-library-cache-v1";
 let toastTimer = null;
 let featuredTimer = null;
 let playRequestId = 0;
@@ -71,6 +72,22 @@ function restorePlaybackState() {
   }
 }
 
+function readCloudLibraryCache() {
+  const raw = localStorage.getItem(cloudLibraryCacheKey);
+  if (!raw) return null;
+  try {
+    return JSON.parse(raw);
+  } catch {
+    localStorage.removeItem(cloudLibraryCacheKey);
+    return null;
+  }
+}
+
+function writeCloudLibraryCache(payload) {
+  if (!payload) return;
+  localStorage.setItem(cloudLibraryCacheKey, JSON.stringify(payload));
+}
+
 async function fetchLibrary() {
   const response = await fetch("/api/library/local");
   state.libraries.local = await response.json();
@@ -80,6 +97,13 @@ async function fetchLibrary() {
   restorePlaybackState();
   if (!state.cloudLoadingStarted) {
     state.cloudLoadingStarted = true;
+    const cachedCloud = readCloudLibraryCache();
+    if (cachedCloud) {
+      state.libraries.cloud = cachedCloud;
+      if (state.activeSource === "cloud") {
+        applyActiveLibrary();
+      }
+    }
     queueMicrotask(() => fetchCloudLibrary());
   }
 }
@@ -88,14 +112,52 @@ async function fetchCloudLibrary() {
   try {
     const response = await fetch("/api/library/cloud");
     state.libraries.cloud = await response.json();
+    writeCloudLibraryCache(state.libraries.cloud);
     if (state.activeSource === "cloud") {
       applyActiveLibrary();
     }
+    queueMicrotask(() => refreshCloudLibraryInBackground());
   } catch (error) {
     console.error("fetchCloudLibrary failed", error);
   }
 }
 
+async function refreshCloudLibraryInBackground() {
+  try {
+    const response = await fetch("/api/library/cloud/refresh");
+    const payload = await response.json();
+    const library = payload?.library;
+    if (!library) return;
+    state.libraries.cloud = library;
+    writeCloudLibraryCache(library);
+    if (state.activeSource === "cloud") {
+      applyActiveLibrary();
+    }
+    if (payload?.started) {
+      window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
+    }
+  } catch (error) {
+    console.error("refreshCloudLibraryInBackground failed", error);
+  }
+}
+
+async function syncCloudLibraryAfterRefresh() {
+  try {
+    const response = await fetch("/api/library/cloud");
+    const payload = await response.json();
+    state.libraries.cloud = payload;
+    writeCloudLibraryCache(payload);
+    if (state.activeSource === "cloud") {
+      applyActiveLibrary();
+    }
+    if (payload?.cache?.refreshing) {
+      window.setTimeout(() => syncCloudLibraryAfterRefresh(), 1500);
+    }
+  } catch (error) {
+    console.error("syncCloudLibraryAfterRefresh failed", error);
+  }
+}
+
 function applyActiveLibrary() {
   const fallback = state.libraries.local;
   state.library = state.libraries[state.activeSource] || fallback;
@@ -131,9 +193,20 @@ function switchLibrary(source) {
   applyActiveLibrary();
   setCurrentFolder(source === "cloud" ? "cloud" : "");
   savePlaybackState();
-  if (source === "cloud" && !state.libraries.cloud && !state.cloudLoadingStarted) {
-    state.cloudLoadingStarted = true;
-    fetchCloudLibrary();
+  if (source === "cloud") {
+    if (!state.libraries.cloud) {
+      const cachedCloud = readCloudLibraryCache();
+      if (cachedCloud) {
+        state.libraries.cloud = cachedCloud;
+        applyActiveLibrary();
+      }
+    }
+    if (!state.cloudLoadingStarted) {
+      state.cloudLoadingStarted = true;
+      fetchCloudLibrary();
+    } else {
+      refreshCloudLibraryInBackground();
+    }
   }
 }