Преглед изворни кода

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

sequoia00 пре 2 недеља
родитељ
комит
8ac2d15ce6
3 измењених фајлова са 229 додато и 30 уклоњено
  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 mimetypes
 import shutil
 import shutil
 import uuid
 import uuid
+import base64
+import urllib.error
+import urllib.parse
+import urllib.request
 from pathlib import Path
 from pathlib import Path
 from typing import Any
 from typing import Any
 
 
 from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
 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.staticfiles import StaticFiles
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 BASE_DIR = Path(__file__).resolve().parent.parent
 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"
 PLAYLISTS_FILE = BASE_DIR / "playlists.json"
 SUPPORTED_EXTENSIONS = {
 SUPPORTED_EXTENSIONS = {
     ".mp3",
     ".mp3",
@@ -57,14 +70,24 @@ class PlaylistUpdateRequest(BaseModel):
 
 
 
 
 def ensure_storage() -> None:
 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():
     if not PLAYLISTS_FILE.exists():
         PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
         PLAYLISTS_FILE.write_text("[]", encoding="utf-8")
 
 
 
 
 def safe_music_path(relative_path: str) -> Path:
 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")
         raise HTTPException(status_code=400, detail="Invalid path")
     return candidate
     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:
 def is_supported_file(path: Path) -> bool:
     return path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS
     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 = (
     candidates = (
         "cover.jpg",
         "cover.jpg",
         "cover.jpeg",
         "cover.jpeg",
@@ -113,37 +218,38 @@ def find_cover_for_directory(root: Path) -> str | None:
     for candidate in candidates:
     for candidate in candidates:
         path = root / candidate
         path = root / candidate
         if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
         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()):
     for child in sorted(root.iterdir(), key=lambda item: item.name.lower()):
         if child.is_file() and child.suffix.lower() in IMAGE_EXTENSIONS:
         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
     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 = {
     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,
         "cover": f"/api/cover/{cover_path}" if cover_path else None,
         "folders": [],
         "folders": [],
         "tracks": [],
         "tracks": [],
     }
     }
     for child in sorted(root.iterdir(), key=lambda item: (item.is_file(), item.name.lower())):
     for child in sorted(root.iterdir(), key=lambda item: (item.is_file(), item.name.lower())):
         if child.is_dir():
         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):
         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
     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]] = []
     tracks: list[dict[str, str]] = []
     for path in sorted(root.rglob("*")):
     for path in sorted(root.rglob("*")):
         if is_supported_file(path):
         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
     return tracks
 
 
 
 
@@ -160,26 +266,101 @@ def index(request: Request) -> HTMLResponse:
 @app.get("/api/library")
 @app.get("/api/library")
 def library() -> JSONResponse:
 def library() -> JSONResponse:
     ensure_storage()
     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(
     return JSONResponse(
         {
         {
-            "tree": build_tree(MUSIC_DIR),
-            "all_tracks": collect_tracks(MUSIC_DIR),
+            "tree": tree,
+            "all_tracks": all_tracks,
             "playlists": load_playlists(),
             "playlists": load_playlists(),
         }
         }
     )
     )
 
 
 
 
 @app.get("/api/stream/{file_path:path}")
 @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)
     file = safe_music_path(file_path)
     if not file.exists() or not file.is_file():
     if not file.exists() or not file.is_file():
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File 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,
+
+    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,
         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=""),
     target_dir: str = Form(default=""),
 ) -> JSONResponse:
 ) -> JSONResponse:
     destination = safe_music_path(target_dir)
     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)
     destination.mkdir(parents=True, exist_ok=True)
     saved: list[str] = []
     saved: list[str] = []
     for upload in files:
     for upload in files:
@@ -208,15 +391,17 @@ async def upload_files(
         file_path = destination / filename
         file_path = destination / filename
         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(file_path.relative_to(MUSIC_DIR).as_posix())
+        saved.append(track_path_from_abs(file_path))
     return JSONResponse({"saved": saved})
     return JSONResponse({"saved": saved})
 
 
 
 
 @app.post("/api/folder")
 @app.post("/api/folder")
 def create_folder(payload: FolderCreateRequest) -> JSONResponse:
 def create_folder(payload: FolderCreateRequest) -> JSONResponse:
     folder = safe_music_path(payload.path)
     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)
     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")
 @app.post("/api/move")
@@ -229,7 +414,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))
-    return JSONResponse({"moved": destination.relative_to(MUSIC_DIR).as_posix()})
+    return JSONResponse({"moved": track_path_from_abs(destination)})
 
 
 
 
 @app.post("/api/playlists")
 @app.post("/api/playlists")

+ 16 - 2
static/js/app.js

@@ -666,6 +666,12 @@ function syncPlayButton() {
 }
 }
 
 
 async function getCachedAudioUrl(track) {
 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;
   if (!("caches" in window)) return track.url;
 
 
   try {
   try {
@@ -692,8 +698,16 @@ async function playTrack(index) {
   if (!state.currentQueue.length) return;
   if (!state.currentQueue.length) return;
   state.currentTrackIndex = index;
   state.currentTrackIndex = index;
   const track = state.currentQueue[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);
   updateDock(track);
   renderQueue();
   renderQueue();
   savePlaybackState();
   savePlaybackState();

+ 1 - 1
templates/index.html

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