feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user