- 初始化

This commit is contained in:
Pine
2026-05-12 22:38:52 +08:00
commit c8e8dd12d2
41 changed files with 9146 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5082
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "dpm"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "dpm_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
{"settings": {"1": {"username": "admin", "password": "123456", "volume": 60, "auto_play": true, "play_mode": "sequential", "image_duration": 8, "control_action": "play"}}, "playlist": {"1": {"path": "\u540d\u7247.png"}, "2": {"path": "02.mp4"}}, "url_media": {"1": {"url": "https://video-qn.ibaotu.com/20/08/52/29f888piCpMR.mp4", "name": "29f888piCpMR.mp4", "type": "video", "added_at": "2026-05-12T20:36:28.512177"}}}
+352
View File
@@ -0,0 +1,352 @@
from pathlib import Path
import asyncio, json
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from tinydb import TinyDB, Query
from datetime import datetime
from typing import Any, Dict, List
app = FastAPI(title="大屏媒体轮播系统")
# ===================== 模板加载 =====================
TEMPLATE_DIR = Path(__file__).parent / "templates"
def _load_template(name: str) -> str:
return (TEMPLATE_DIR / name).read_text(encoding="utf-8")
SCREEN_HTML = _load_template("screen.html")
ADMIN_HTML = _load_template("admin.html")
# ===================== SSE 事件管理器 =====================
class EventManager:
def __init__(self):
self._queues: list[asyncio.Queue] = []
def subscribe(self, q: asyncio.Queue):
self._queues.append(q)
def unsubscribe(self, q: asyncio.Queue):
try:
self._queues.remove(q)
except ValueError:
pass
def broadcast(self, event: dict):
data = json.dumps(event)
dead = []
for q in self._queues:
try:
q.put_nowait(data)
except asyncio.QueueFull:
dead.append(q)
for q in dead:
self.unsubscribe(q)
events = EventManager()
# ===================== 播放状态(内存) =====================
playback_state: dict = {
"status": "idle", # playing | paused | idle
"index": 0,
"name": "",
"type": "",
}
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ===================== 配置 =====================
MEDIA_DIR = Path.home() / "Downloads" / "Media"
MEDIA_DIR.mkdir(exist_ok=True, parents=True)
DB = TinyDB("db.json")
FILES_TABLE = DB.table("files")
PLAYLIST_TABLE = DB.table("playlist")
SETTINGS_TABLE = DB.table("settings")
URL_MEDIA_TABLE = DB.table("url_media")
User = Query()
FileQuery = Query()
UrlMediaQuery = Query()
ALLOWED_EXTENSIONS = {".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"}
# ===================== 初始化 =====================
DEFAULT_SETTINGS = {
"username": "admin",
"password": "123456",
"volume": 80,
"auto_play": True,
"play_mode": "sequential",
"image_duration": 5,
}
if not SETTINGS_TABLE.search(User.username == "admin"):
SETTINGS_TABLE.insert(DEFAULT_SETTINGS)
else:
# 补齐缺失的默认字段
current = SETTINGS_TABLE.get(User.username == "admin")
updated = False
for key, val in DEFAULT_SETTINGS.items():
if key not in current:
current[key] = val
updated = True
if updated:
SETTINGS_TABLE.update(current, User.username == "admin")
# ===================== 前端页面 =====================
@app.get("/", response_class=HTMLResponse)
async def screen_page():
return SCREEN_HTML
@app.get("/admin", response_class=HTMLResponse)
async def admin_page():
return ADMIN_HTML
# ===================== 文件访问 =====================
from fastapi.responses import FileResponse
@app.get("/file/{path:path}")
async def get_file(path: str):
target = MEDIA_DIR / path
if not target.exists() or not target.is_file():
raise HTTPException(404)
return FileResponse(target)
# ===================== API =====================
@app.post("/api/login")
async def login(username: str = Form(...), password: str = Form(...)):
admin = SETTINGS_TABLE.get(User.username == "admin")
if admin and admin["password"] == password:
return {"success": True}
return {"success": False}
@app.get("/api/settings")
async def get_settings():
s = SETTINGS_TABLE.get(User.username == "admin")
return {k: s[k] for k in DEFAULT_SETTINGS if k in s}
@app.post("/api/settings")
async def update_settings(data: dict):
"""统一设置更新,支持 volume/play_mode/image_duration"""
allowed = {"volume", "play_mode", "image_duration"}
update = {k: v for k, v in data.items() if k in allowed}
if update:
SETTINGS_TABLE.update(update, User.username == "admin")
events.broadcast({"action": "settings_changed", **update})
return {"ok": True}
@app.get("/media")
async def get_media_files():
files = []
# 本地文件
for p in MEDIA_DIR.rglob("*"):
if not p.is_file(): continue
ext = p.suffix.lower()
if ext not in ALLOWED_EXTENSIONS: continue
rel = str(p.relative_to(MEDIA_DIR))
t = "video" if ext in [".mp4",".mkv",".avi"] else "image"
files.append({
"name": p.name,
"relative_path": rel,
"type": t,
"url": f"/file/{rel}",
"source": "local",
})
# URL 媒体(持久化在 url_media 表中)
for item in URL_MEDIA_TABLE.all():
files.append({
"name": item["name"],
"relative_path": item["url"],
"type": item["type"],
"url": item["url"],
"source": "url",
})
return {"files": files}
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400)
ts = datetime.now().strftime("%Y%m%d%H%M%S")
name = f"{Path(file.filename).stem}_{ts}{ext}"
path = MEDIA_DIR / name
with open(path, "wb") as f:
f.write(await file.read())
return {"ok": True}
@app.post("/api/delete")
async def delete_file(data: dict):
path = data["path"]
if path.startswith(("http://", "https://")):
# 删除 URL 媒体及其播放列表引用
URL_MEDIA_TABLE.remove(UrlMediaQuery.url == path)
else:
p = MEDIA_DIR / path
if p.exists() and p.is_file():
p.unlink()
PLAYLIST_TABLE.remove(FileQuery.path == path)
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.get("/api/playlist")
async def get_playlist():
items = PLAYLIST_TABLE.all()
admin = SETTINGS_TABLE.get(User.username == "admin")
files = []
for item in items:
if item.get("source") == "url":
ext = Path(item["path"]).suffix.lower()
files.append({
"name": item.get("name", item["path"]),
"relative_path": item["path"],
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"source": "url",
})
else:
p = MEDIA_DIR / item["path"]
if p.exists():
ext = p.suffix.lower()
files.append({
"name": p.name,
"relative_path": item["path"],
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"source": "local",
})
return {
"files": files,
"volume": admin["volume"],
"play_mode": admin.get("play_mode", "sequential"),
"image_duration": admin.get("image_duration", 5),
}
@app.post("/api/playlist/add")
async def add_playlist(data: dict):
path = data["path"]
if not PLAYLIST_TABLE.contains(FileQuery.path == path):
item: dict = {"path": path}
if path.startswith(("http://", "https://")):
item["source"] = "url"
# 从 URL 媒体库中获取名称
um = URL_MEDIA_TABLE.get(UrlMediaQuery.url == path)
if um:
item["name"] = um["name"]
PLAYLIST_TABLE.insert(item)
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.post("/api/playlist/remove")
async def remove_playlist(data: dict):
PLAYLIST_TABLE.remove(FileQuery.path == data["path"])
events.broadcast({"action": "playlist_changed"})
return {"ok": True}
@app.post("/api/media/add-url")
async def add_url_media(data: dict):
"""添加 URL 到媒体库(持久化),不自动加入播放列表"""
url = data.get("url", "").strip()
if not url:
raise HTTPException(400, "url 不能为空")
if URL_MEDIA_TABLE.contains(UrlMediaQuery.url == url):
return {"ok": True, "duplicate": True}
ext = Path(url.split("?")[0]).suffix.lower()
name = data.get("name", "").strip() or url.rsplit("/", 1)[-1].split("?")[0]
URL_MEDIA_TABLE.insert({
"url": url,
"name": name,
"type": "video" if ext in [".mp4",".mkv",".avi"] else "image",
"added_at": datetime.now().isoformat(),
})
return {"ok": True}
@app.get("/api/files")
async def list_files_with_url():
"""输出媒体文件夹中所有文件的 URL"""
files = []
for p in sorted(MEDIA_DIR.rglob("*")):
if not p.is_file(): continue
ext = p.suffix.lower()
if ext not in ALLOWED_EXTENSIONS: continue
rel = str(p.relative_to(MEDIA_DIR))
t = "video" if ext in [".mp4",".mkv",".avi"] else "image"
files.append({
"name": p.name,
"type": t,
"url": f"/file/{rel}",
})
return {"files": files}
@app.get("/api/events")
async def sse_events(request: Request):
"""SSE 端点,向后端推送实时播放控制指令"""
q: asyncio.Queue = asyncio.Queue(maxsize=32)
events.subscribe(q)
async def generate():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(q.get(), timeout=15)
yield f"data: {data}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n"
finally:
events.unsubscribe(q)
return StreamingResponse(generate(), media_type="text/event-stream")
@app.post("/api/control")
async def playback_control(data: dict):
"""后端控制播放:{action: 'play'|'pause'|'next'|'prev'}"""
action = data.get("action", "")
if action not in ("play", "pause", "next", "prev"):
raise HTTPException(400, "action 必须是 play/pause/next/prev")
SETTINGS_TABLE.update({"control_action": action}, User.username == "admin")
events.broadcast({"action": action})
# 更新本地状态(乐观)
if action == "play":
playback_state["status"] = "playing"
elif action == "pause":
playback_state["status"] = "paused"
events.broadcast({"action": "state_update", "state": playback_state})
return {"ok": True, "action": action}
@app.get("/api/state")
async def get_state():
"""获取当前播放状态"""
return {"state": playback_state}
@app.post("/api/state")
async def update_state(data: dict):
"""前端上报播放状态变更"""
state = data.get("state", {})
if "status" in state:
playback_state["status"] = state["status"]
if "index" in state:
playback_state["index"] = state["index"]
if "name" in state:
playback_state["name"] = state["name"]
if "type" in state:
playback_state["type"] = state["type"]
events.broadcast({"action": "state_update", "state": playback_state})
return {"ok": True}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True
)
+14
View File
@@ -0,0 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
dpm_lib::run()
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "dpm",
"version": "0.1.0",
"identifier": "com.pine.dpm",
"build": {
"beforeDevCommand": "yarn dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "yarn build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "dpm",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}