Przeglądaj źródła

增加百度网盘+Alist进行播放

sequoia00 2 tygodni temu
rodzic
commit
8ac2d15ce6
3 zmienionych plików z 229 dodań i 30 usunięć
  1. 212 27
      app/main.py
  2. 16 2
      static/js/app.js
  3. 1 1
      templates/index.html

+ 212 - 27
app/main.py

@@ -4,17 +4,30 @@ import json
 import mimetypes
 import shutil
 import uuid
+import base64
+import urllib.error
+import urllib.parse
+import urllib.request
 from pathlib import Path
 from typing import Any
 
 from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.templating import Jinja2Templates
 from pydantic import BaseModel
 
 BASE_DIR = Path(__file__).resolve().parent.parent
-MUSIC_DIR = BASE_DIR / "mp3file"
+LOCAL_MUSIC_DIR = BASE_DIR / "mp3file"
+CLOUD_MUSIC_DIR = Path("/mnt/baiducloud/百度网盘/mp3file")
+CLOUD_WEBDAV_BASE = "http://110.42.102.94:5244/dav"
+CLOUD_ALIST_BASE = "http://110.42.102.94:5244"
+CLOUD_WEBDAV_USER = "sequoia00"
+CLOUD_WEBDAV_PASSWORD = "792199bb"
+MUSIC_ROOTS: list[tuple[str, Path, str]] = [
+    ("", LOCAL_MUSIC_DIR, "音乐库"),
+    ("cloud", CLOUD_MUSIC_DIR, "百度网盘"),
+]
 PLAYLISTS_FILE = BASE_DIR / "playlists.json"
 SUPPORTED_EXTENSIONS = {
     ".mp3",
@@ -57,14 +70,24 @@ class PlaylistUpdateRequest(BaseModel):
 
 
 def ensure_storage() -> None:
-    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)
     if not PLAYLISTS_FILE.exists():
         PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
 
 
 def safe_music_path(relative_path: str) -> Path:
-    candidate = (MUSIC_DIR / relative_path).resolve()
-    if candidate != MUSIC_DIR.resolve() and MUSIC_DIR.resolve() not in candidate.parents:
+    prefix, _, inner_path = relative_path.partition("/")
+    roots = {key: root.resolve() for key, root, _ in MUSIC_ROOTS}
+    if prefix in roots:
+        candidate = (roots[prefix] / inner_path).resolve()
+        if candidate != roots[prefix] and roots[prefix] not in candidate.parents:
+            raise HTTPException(status_code=400, detail="Invalid path")
+        return candidate
+
+    candidate = (LOCAL_MUSIC_DIR / relative_path).resolve()
+    local_root = LOCAL_MUSIC_DIR.resolve()
+    if candidate != local_root and local_root not in candidate.parents:
         raise HTTPException(status_code=400, detail="Invalid path")
     return candidate
 
@@ -80,6 +103,88 @@ def save_playlists(playlists: list[dict[str, Any]]) -> None:
     )
 
 
+def is_under_root(path: Path, root: Path) -> bool:
+    resolved_path = path.resolve()
+    resolved_root = root.resolve()
+    return resolved_path == resolved_root or resolved_root in resolved_path.parents
+
+
+def track_path_for(root: Path, path: Path, prefix: str = "") -> str:
+    relative = path.relative_to(root).as_posix()
+    return f"{prefix}/{relative}" if prefix else relative
+
+
+def track_path_from_abs(path: Path) -> str:
+    if is_under_root(path, LOCAL_MUSIC_DIR):
+        return path.relative_to(LOCAL_MUSIC_DIR).as_posix()
+    if is_under_root(path, CLOUD_MUSIC_DIR):
+        return f"cloud/{path.relative_to(CLOUD_MUSIC_DIR).as_posix()}"
+    return path.name
+
+
+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)
+    base = CLOUD_WEBDAV_BASE.rstrip("/")
+    return f"{base}/{encoded}" if encoded else base
+
+
+def cloud_alist_api_path(relative_path: str) -> str:
+    inner_path = relative_path.removeprefix("cloud/").lstrip("/")
+    return f"/百度网盘/mp3file/{inner_path}" if inner_path else "/百度网盘/mp3file"
+
+
+def alist_login_token() -> str:
+    payload = json.dumps(
+        {
+            "username": CLOUD_WEBDAV_USER,
+            "password": CLOUD_WEBDAV_PASSWORD,
+        }
+    ).encode("utf-8")
+    request = urllib.request.Request(
+        f"{CLOUD_ALIST_BASE.rstrip('/')}/api/auth/login",
+        data=payload,
+        headers={"Content-Type": "application/json", "User-Agent": "MusicWebPlayer/1.0"},
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=20) as response:
+            result = json.loads(response.read().decode("utf-8"))
+    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))
+
+    token = result.get("data", {}).get("token")
+    if not token:
+        raise HTTPException(status_code=502, detail="AList login failed")
+    return token
+
+
+def cloud_raw_url(relative_path: str) -> str:
+    payload = json.dumps({"path": cloud_alist_api_path(relative_path), "password": ""}).encode("utf-8")
+    request = urllib.request.Request(
+        f"{CLOUD_ALIST_BASE.rstrip('/')}/api/fs/get",
+        data=payload,
+        headers={
+            "Content-Type": "application/json",
+            "Authorization": alist_login_token(),
+            "User-Agent": "MusicWebPlayer/1.0",
+        },
+    )
+    try:
+        with urllib.request.urlopen(request, timeout=20) as response:
+            result = json.loads(response.read().decode("utf-8"))
+    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))
+
+    raw_url = result.get("data", {}).get("raw_url")
+    if not raw_url:
+        raise HTTPException(status_code=404, detail="Cloud raw url not found")
+    return raw_url
+
+
 def is_supported_file(path: Path) -> bool:
     return path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
 
@@ -99,7 +204,7 @@ def build_track(relative_path: str) -> dict[str, str]:
     }
 
 
-def find_cover_for_directory(root: Path) -> str | None:
+def find_cover_for_directory(root: Path, prefix: str = "") -> str | None:
     candidates = (
         "cover.jpg",
         "cover.jpeg",
@@ -113,37 +218,38 @@ def find_cover_for_directory(root: Path) -> str | None:
     for candidate in candidates:
         path = root / candidate
         if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
-            return path.relative_to(MUSIC_DIR).as_posix()
+            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 child.relative_to(MUSIC_DIR).as_posix()
+            return track_path_for(root, child, prefix)
     return None
 
 
-def build_tree(root: Path) -> dict[str, Any]:
-    rel_root = root.relative_to(MUSIC_DIR) if root != MUSIC_DIR else Path(".")
-    cover_path = find_cover_for_directory(root)
+def build_tree(root: Path, prefix: str = "", label: str = "音乐库") -> dict[str, Any]:
+    cover_path = find_cover_for_directory(root, prefix)
     node = {
-        "name": "音乐库" if root == MUSIC_DIR else root.name,
-        "path": "" if rel_root == Path(".") else rel_root.as_posix(),
+        "name": label,
+        "path": prefix,
         "cover": f"/api/cover/{cover_path}" if cover_path else None,
         "folders": [],
         "tracks": [],
     }
     for child in sorted(root.iterdir(), key=lambda item: (item.is_file(), item.name.lower())):
         if child.is_dir():
-            node["folders"].append(build_tree(child))
+            child_rel = child.relative_to(root).as_posix()
+            child_prefix = f"{prefix}/{child_rel}" if prefix else child_rel
+            node["folders"].append(build_tree(child, child_prefix, child.name))
         elif is_supported_file(child):
-            node["tracks"].append(build_track(child.relative_to(MUSIC_DIR).as_posix()))
+            node["tracks"].append(build_track(track_path_for(root, child, prefix)))
     return node
 
 
-def collect_tracks(root: Path) -> list[dict[str, str]]:
+def collect_tracks(root: Path, prefix: str = "") -> list[dict[str, str]]:
     tracks: list[dict[str, str]] = []
     for path in sorted(root.rglob("*")):
         if is_supported_file(path):
-            tracks.append(build_track(path.relative_to(MUSIC_DIR).as_posix()))
+            tracks.append(build_track(track_path_for(root, path, prefix)))
     return tracks
 
 
@@ -160,26 +266,101 @@ def index(request: Request) -> HTMLResponse:
 @app.get("/api/library")
 def library() -> JSONResponse:
     ensure_storage()
+    tree = {
+        "name": "音乐库",
+        "path": "",
+        "cover": None,
+        "folders": [
+            build_tree(root, prefix, label)
+            for prefix, root, label in MUSIC_ROOTS
+            if root.exists()
+        ],
+        "tracks": [],
+    }
+    all_tracks: list[dict[str, str]] = []
+    for prefix, root, _ in MUSIC_ROOTS:
+        if root.exists():
+            all_tracks.extend(collect_tracks(root, prefix))
     return JSONResponse(
         {
-            "tree": build_tree(MUSIC_DIR),
-            "all_tracks": collect_tracks(MUSIC_DIR),
+            "tree": tree,
+            "all_tracks": all_tracks,
             "playlists": load_playlists(),
         }
     )
 
 
 @app.get("/api/stream/{file_path:path}")
-def stream_file(file_path: str) -> FileResponse:
+def stream_file(request: Request, file_path: str) -> StreamingResponse:
+    if file_path.startswith("cloud/"):
+        return RedirectResponse(cloud_raw_url(file_path), status_code=307)
+
     file = safe_music_path(file_path)
     if not file.exists() or not file.is_file():
         raise HTTPException(status_code=404, detail="File not found")
     media_type = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
-    return FileResponse(
-        file,
+
+    def file_iterator() -> Any:
+        with file.open("rb") as handle:
+            while chunk := handle.read(1024 * 1024):
+                if not chunk:
+                    break
+                yield chunk
+
+    return StreamingResponse(
+        file_iterator(),
+        media_type=media_type,
+        headers={"Cache-Control": "private, no-cache"},
+    )
+
+
+@app.get("/api/cloud-url/{file_path:path}")
+def get_cloud_url(file_path: str) -> JSONResponse:
+    if not file_path.startswith("cloud/"):
+        raise HTTPException(status_code=400, detail="Not a cloud track")
+    return JSONResponse({"url": cloud_raw_url(file_path)})
+
+
+def proxy_cloud_stream(remote_url: str, range_header: str | None = None) -> StreamingResponse:
+    auth = base64.b64encode(f"{CLOUD_WEBDAV_USER}:{CLOUD_WEBDAV_PASSWORD}".encode("utf-8")).decode("ascii")
+    headers = {
+        "Authorization": f"Basic {auth}",
+        "User-Agent": "MusicWebPlayer/1.0",
+    }
+    if range_header:
+        headers["Range"] = range_header
+    request = urllib.request.Request(
+        remote_url,
+        headers=headers,
+    )
+    try:
+        response = urllib.request.urlopen(request, timeout=30)
+    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))
+
+    media_type = mimetypes.guess_type(urllib.parse.urlparse(remote_url).path)[0] or response.headers.get_content_type() or "audio/mpeg"
+    passthrough_headers = {
+        "Cache-Control": "private, no-cache",
+        "Accept-Ranges": response.headers.get("Accept-Ranges") or "bytes",
+        "Content-Type": media_type,
+    }
+    for key in ("ETag", "Last-Modified", "Content-Range"):
+        value = response.headers.get(key)
+        if value:
+            passthrough_headers[key] = value
+
+    def remote_iterator() -> Any:
+        with response:
+            while chunk := response.read(1024 * 1024):
+                yield chunk
+
+    return StreamingResponse(
+        remote_iterator(),
+        status_code=getattr(response, "status", 200),
         media_type=media_type,
-        filename=file.name,
-        headers={"Cache-Control": "private, max-age=31536000, immutable"},
+        headers=passthrough_headers,
     )
 
 
@@ -198,6 +379,8 @@ async def upload_files(
     target_dir: str = Form(default=""),
 ) -> JSONResponse:
     destination = safe_music_path(target_dir)
+    if not str(destination).startswith(str(LOCAL_MUSIC_DIR.resolve())):
+        raise HTTPException(status_code=400, detail="Upload destination must be local music dir")
     destination.mkdir(parents=True, exist_ok=True)
     saved: list[str] = []
     for upload in files:
@@ -208,15 +391,17 @@ async def upload_files(
         file_path = destination / filename
         with file_path.open("wb") as buffer:
             shutil.copyfileobj(upload.file, buffer)
-        saved.append(file_path.relative_to(MUSIC_DIR).as_posix())
+        saved.append(track_path_from_abs(file_path))
     return JSONResponse({"saved": saved})
 
 
 @app.post("/api/folder")
 def create_folder(payload: FolderCreateRequest) -> JSONResponse:
     folder = safe_music_path(payload.path)
+    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)
-    return JSONResponse({"created": folder.relative_to(MUSIC_DIR).as_posix()})
+    return JSONResponse({"created": track_path_from_abs(folder)})
 
 
 @app.post("/api/move")
@@ -229,7 +414,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))
-    return JSONResponse({"moved": destination.relative_to(MUSIC_DIR).as_posix()})
+    return JSONResponse({"moved": track_path_from_abs(destination)})
 
 
 @app.post("/api/playlists")

+ 16 - 2
static/js/app.js

@@ -666,6 +666,12 @@ function syncPlayButton() {
 }
 
 async function getCachedAudioUrl(track) {
+  if (track.path?.startsWith("cloud/")) {
+    const response = await fetch(`/api/cloud-url/${track.path}`);
+    if (!response.ok) throw new Error(`cloud url request failed: ${response.status}`);
+    const data = await response.json();
+    return data.url || track.url;
+  }
   if (!("caches" in window)) return track.url;
 
   try {
@@ -692,8 +698,16 @@ async function playTrack(index) {
   if (!state.currentQueue.length) return;
   state.currentTrackIndex = index;
   const track = state.currentQueue[index];
-  audio.src = await getCachedAudioUrl(track);
-  audio.play();
+  try {
+    audio.src = await getCachedAudioUrl(track);
+    await audio.play();
+  } catch (error) {
+    console.error("playTrack failed", track?.path, error);
+    audio.src = track.url;
+    audio.play().catch((fallbackError) => {
+      console.error("fallback play failed", track?.path, fallbackError);
+    });
+  }
   updateDock(track);
   renderQueue();
   savePlaybackState();

+ 1 - 1
templates/index.html

@@ -110,7 +110,7 @@
         </section>
 
         <section class="stack-section">
-          <section class="content-section">
+          <section class="content-section is-hidden">
             <div class="section-head">
               <h2>目录与上传</h2>
             </div>