feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标

This commit is contained in:
Nixevol
2026-09-27 06:16:50 +08:00
parent f010426f94
commit 1b33898fff
81 changed files with 2529 additions and 232 deletions
+112
View File
@@ -0,0 +1,112 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from server.errors import call
from server.schemas import DeviceBody, device_payload
from server.services.downloads import downloads
router = APIRouter(prefix="/api/downloads")
class DownloadItem(BaseModel):
id: int
title: str = ""
image: str = ""
class DownloadStartBody(BaseModel):
token: str = ""
device: DeviceBody | None = None
items: list[DownloadItem] = []
class DirectoryBody(BaseModel):
path: str = ""
class DownloadSettingsBody(BaseModel):
directory: str = ""
workers: int | None = None
@router.get("")
def list_downloads():
return downloads.snapshot()
@router.post("")
def start_downloads(body: DownloadStartBody):
return downloads.enqueue(body.token, device_payload(body.device), [item.model_dump() for item in body.items])
@router.post("/directory")
def set_directory(body: DirectoryBody):
if not body.path.strip():
raise HTTPException(status_code=400, detail="请选择目录")
return {"directory": downloads.set_directory(body.path.strip())}
@router.post("/pick")
def pick_directory():
return {"directory": downloads.pick_directory()}
@router.post("/choose")
def choose_directory():
return {"directory": downloads.choose_directory()}
@router.get("/settings")
def get_download_settings():
return downloads.settings_view()
@router.post("/settings")
def update_download_settings(body: DownloadSettingsBody):
if body.directory.strip():
downloads.set_directory(body.directory.strip())
if body.workers is not None:
try:
downloads.set_workers(body.workers)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return downloads.settings_view()
@router.post("/pause-all")
def pause_all_downloads():
return downloads.pause_all()
@router.post("/resume-all")
def resume_all_downloads():
return downloads.resume_all()
@router.post("/{drama_id}/pause")
def pause_download(drama_id: str):
return downloads.pause(drama_id)
@router.post("/{drama_id}/resume")
def resume_download(drama_id: str):
return downloads.resume(drama_id)
@router.post("/{drama_id}/cancel")
def cancel_download(drama_id: str):
return downloads.cancel(drama_id)
@router.post("/{drama_id}/open")
def open_download(drama_id: str):
def action():
downloads.open_folder(drama_id)
return downloads.snapshot()
return call(action)
@router.delete("/{drama_id}")
def remove_download(drama_id: str):
return downloads.remove(drama_id)
+33
View File
@@ -0,0 +1,33 @@
import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from server.services.downloads import downloads
from server.services.live import live
from server.services.sessions import sessions
router = APIRouter()
def _payload() -> dict:
profile = sessions.snapshot()
return {
"downloads": downloads.snapshot(),
"profile": profile["profile"] if profile else None,
}
@router.websocket("/api/live")
async def live_socket(websocket: WebSocket):
await websocket.accept()
seen = -1
try:
while True:
revision = await asyncio.to_thread(live.wait, seen, 20)
if revision != seen:
seen = revision
await websocket.send_json(_payload())
else:
await websocket.send_json({"type": "ping"})
except WebSocketDisconnect:
return
+89
View File
@@ -0,0 +1,89 @@
from urllib.parse import urlsplit
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from server.schemas import DeviceBody, device_payload
from server.services.device import DeviceProfile
from server.services.http import http
from server.services import playlist as playlists
from server.services.sessions import sessions
router = APIRouter(prefix="/api/play")
class PlayBody(BaseModel):
token: str = ""
device: DeviceBody | None = None
vid: int
def _device(token: str, raw: dict | None) -> DeviceProfile | None:
if not token:
return None
if raw:
return sessions.bind(token, DeviceProfile.merge(raw))
return sessions.device_for(token)
def _public_url(url: str) -> str:
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.hostname:
raise HTTPException(status_code=400, detail="播放地址无效")
return url
@router.post("/episodes")
def list_episodes(body: PlayBody):
device = _device(body.token, device_payload(body.device))
try:
rows = playlists.load_episodes(body.token, device, body.vid)
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"episodes": [
{
"cid": ep["cid"],
"idx": ep["idx"],
"title": ep["title"],
"playable": bool(ep.get("src")),
}
for ep in rows
]
}
@router.get("/segment")
def play_segment(url: str):
target = _public_url(url)
try:
upstream = http.open_stream(target)
except (httpx.HTTPError, TimeoutError, OSError, RuntimeError) as exc:
raise HTTPException(status_code=502, detail="分片读取失败") from exc
media = upstream.headers.get("content-type", "video/mp2t").split(";", 1)[0].strip() or "video/mp2t"
def chunks():
try:
for chunk in upstream.iter_bytes(64 * 1024):
if chunk:
yield chunk
finally:
upstream.close()
return StreamingResponse(chunks(), media_type=media)
@router.get("/{vid}/{cid}/index.m3u8")
def episode_playlist(vid: int, cid: int):
token = sessions.active_token()
device = sessions.device_for(token) if token else None
src = playlists.episode_src(token, device, vid, cid)
if not src:
raise HTTPException(status_code=404, detail="这一集还没有可播放的地址")
try:
text = playlists.playback_playlist(src)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return Response(text, media_type="application/vnd.apple.mpegurl", headers={"Cache-Control": "no-store"})