- tauri 设计完成
This commit is contained in:
Generated
+227
@@ -213,6 +213,71 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -460,8 +525,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -797,11 +864,21 @@ dependencies = [
|
||||
name = "dpm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -866,6 +943,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1036,6 +1122,21 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -1043,6 +1144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1110,6 +1212,7 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1501,12 +1604,24 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -1520,6 +1635,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -1976,6 +2092,12 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -1997,6 +2119,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2039,6 +2171,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2796,6 +2945,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2957,6 +3112,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -2986,6 +3152,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.20.0"
|
||||
@@ -3158,6 +3336,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3695,11 +3879,37 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3846,6 +4056,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3856,10 +4067,19 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -3884,6 +4104,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -4001,6 +4222,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
+12
-8
@@ -1,16 +1,11 @@
|
||||
[package]
|
||||
name = "dpm"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
description = "大屏媒体轮播系统 - Tauri App"
|
||||
authors = ["Pine"]
|
||||
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"]
|
||||
|
||||
@@ -22,4 +17,13 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.8", features = ["macros", "multipart"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tower = "0.5"
|
||||
once_cell = "1"
|
||||
dirs = "6"
|
||||
futures = "0.3"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}}}
|
||||
@@ -1,352 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::models::ServerEvent;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// SSE 事件管理器
|
||||
pub struct EventManager {
|
||||
tx: broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl EventManager {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(64);
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// 广播事件给所有 SSE 客户端
|
||||
pub fn broadcast(&self, event: &ServerEvent) {
|
||||
if let Ok(json) = serde_json::to_string(event) {
|
||||
let _ = self.tx.send(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取广播接收器
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<String> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
pub static EVENTS: Lazy<EventManager> = Lazy::new(EventManager::new);
|
||||
+12
-6
@@ -1,14 +1,20 @@
|
||||
// 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)
|
||||
}
|
||||
mod events;
|
||||
mod models;
|
||||
mod server;
|
||||
mod storage;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// 在后台线程启动 HTTP 服务器
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime");
|
||||
rt.block_on(async {
|
||||
server::start().await;
|
||||
});
|
||||
});
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============ Settings ============
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub volume: u32,
|
||||
pub auto_play: bool,
|
||||
pub play_mode: String,
|
||||
pub image_duration: u32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
username: "admin".into(),
|
||||
password: "123456".into(),
|
||||
volume: 80,
|
||||
auto_play: true,
|
||||
play_mode: "sequential".into(),
|
||||
image_duration: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Playlist Item ============
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaylistItem {
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
// ============ URL Media Item ============
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UrlMediaItem {
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub media_type: String,
|
||||
pub added_at: String,
|
||||
}
|
||||
|
||||
// ============ Media File (response) ============
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MediaFile {
|
||||
pub name: String,
|
||||
pub relative_path: String,
|
||||
#[serde(rename = "type")]
|
||||
pub file_type: String,
|
||||
pub url: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
// ============ Playback State ============
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlaybackState {
|
||||
pub status: String,
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
impl Default for PlaybackState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: "idle".into(),
|
||||
index: 0,
|
||||
name: String::new(),
|
||||
media_type: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Events ============
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ServerEvent {
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<PlaybackState>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub play_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_duration: Option<u32>,
|
||||
}
|
||||
|
||||
// ============ Playlist Response ============
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlaylistResponse {
|
||||
pub files: Vec<MediaFile>,
|
||||
pub volume: u32,
|
||||
pub play_mode: String,
|
||||
pub image_duration: u32,
|
||||
}
|
||||
|
||||
// ============ API Request Bodies ============
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SettingsUpdate {
|
||||
pub volume: Option<u32>,
|
||||
pub play_mode: Option<String>,
|
||||
pub image_duration: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PathBody {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UrlBody {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActionBody {
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StateBody {
|
||||
pub state: PlaybackState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OkDuplicateResponse {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duplicate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FilesResponse {
|
||||
pub files: Vec<MediaFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StateResponse {
|
||||
pub state: PlaybackState,
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
use crate::events::EVENTS;
|
||||
use crate::models::*;
|
||||
use crate::storage::{get_media_type, is_allowed_extension, STORAGE};
|
||||
use axum::{
|
||||
extract::{Form, Multipart, State},
|
||||
http::StatusCode,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::Json,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use chrono::Local;
|
||||
use futures::stream::Stream;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::fs;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
// ===================== 媒体目录 =====================
|
||||
fn media_dir() -> PathBuf {
|
||||
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
path.push("Downloads");
|
||||
path.push("Media");
|
||||
fs::create_dir_all(&path).ok();
|
||||
path
|
||||
}
|
||||
|
||||
fn media_dir_str() -> String {
|
||||
media_dir().to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub playback: Arc<tokio::sync::Mutex<PlaybackState>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
playback: Arc::new(tokio::sync::Mutex::new(PlaybackState::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 路由构建 =====================
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/login", post(login_handler))
|
||||
.route("/api/settings", get(get_settings).post(update_settings))
|
||||
.route("/media", get(list_media))
|
||||
.route("/upload", post(upload_handler))
|
||||
.route("/api/delete", post(delete_handler))
|
||||
.route("/api/media/add-url", post(add_url_handler))
|
||||
.route("/api/playlist", get(get_playlist))
|
||||
.route("/api/playlist/add", post(add_playlist))
|
||||
.route("/api/playlist/remove", post(remove_playlist))
|
||||
.route("/api/control", post(control_handler))
|
||||
.route("/api/state", get(get_state).post(update_state))
|
||||
.route("/api/events", get(sse_handler))
|
||||
.nest_service("/file", ServeDir::new(media_dir_str()))
|
||||
.fallback_service(
|
||||
ServeDir::new("../dist")
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(ServeFile::new("../dist/index.html")),
|
||||
)
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn start() {
|
||||
let state = AppState::new();
|
||||
let app = create_router(state);
|
||||
|
||||
println!("🚀 大屏媒体轮播系统启动于 http://0.0.0.0:8000");
|
||||
println!(" - 管理后台: http://localhost:8000/admin");
|
||||
println!(" - 大屏展示: http://localhost:8000/screen");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000")
|
||||
.await
|
||||
.expect("无法绑定 0.0.0.0:8000,请检查端口是否被占用");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("服务器启动失败");
|
||||
}
|
||||
|
||||
// ===================== Handler: 登录 =====================
|
||||
async fn login_handler(
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Json<LoginResponse> {
|
||||
let s = STORAGE.get_settings();
|
||||
Json(LoginResponse {
|
||||
success: form.username == s.username && form.password == s.password,
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== Handler: 设置 =====================
|
||||
async fn get_settings() -> Json<serde_json::Value> {
|
||||
let s = STORAGE.get_settings();
|
||||
Json(serde_json::json!({
|
||||
"volume": s.volume,
|
||||
"play_mode": s.play_mode,
|
||||
"image_duration": s.image_duration,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
Json(body): Json<SettingsUpdate>,
|
||||
) -> Json<serde_json::Value> {
|
||||
STORAGE.update_settings(SettingsUpdate {
|
||||
volume: body.volume,
|
||||
play_mode: body.play_mode.clone(),
|
||||
image_duration: body.image_duration,
|
||||
});
|
||||
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "settings_changed".into(),
|
||||
state: None,
|
||||
volume: body.volume,
|
||||
play_mode: body.play_mode,
|
||||
image_duration: body.image_duration,
|
||||
});
|
||||
|
||||
Json(serde_json::json!({"ok": true}))
|
||||
}
|
||||
|
||||
// ===================== Handler: 媒体列表 =====================
|
||||
async fn list_media() -> Json<FilesResponse> {
|
||||
let mut files = Vec::new();
|
||||
let dir = media_dir();
|
||||
|
||||
// 本地文件
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !is_allowed_extension(&name) {
|
||||
continue;
|
||||
}
|
||||
let rel = name.clone();
|
||||
let t = get_media_type(&name).to_string();
|
||||
files.push(MediaFile {
|
||||
name,
|
||||
relative_path: rel.clone(),
|
||||
file_type: t,
|
||||
url: format!("/file/{}", rel),
|
||||
source: "local".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// URL 媒体
|
||||
for item in STORAGE.get_url_media() {
|
||||
files.push(MediaFile {
|
||||
name: item.name,
|
||||
relative_path: item.url.clone(),
|
||||
file_type: item.media_type,
|
||||
url: item.url,
|
||||
source: "url".into(),
|
||||
});
|
||||
}
|
||||
|
||||
Json(FilesResponse { files })
|
||||
}
|
||||
|
||||
// ===================== Handler: 上传 =====================
|
||||
async fn upload_handler(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let file_name = field.file_name().unwrap_or("file").to_string();
|
||||
let ext = std::path::Path::new(&file_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{}", e.to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_allowed_extension(&ext) {
|
||||
return Err((StatusCode::BAD_REQUEST, "不支持的文件类型".into()));
|
||||
}
|
||||
|
||||
let ts = Local::now().format("%Y%m%d%H%M%S");
|
||||
let stem = std::path::Path::new(&file_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file");
|
||||
let new_name = format!("{}_{}{}", stem, ts, ext);
|
||||
let save_path = media_dir().join(&new_name);
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
fs::write(&save_path, &data).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
// ===================== Handler: 删除 =====================
|
||||
async fn delete_handler(
|
||||
Json(body): Json<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
let path = body.path;
|
||||
if !path.starts_with("http://") && !path.starts_with("https://") {
|
||||
let file_path = media_dir().join(&path);
|
||||
if file_path.exists() && file_path.is_file() {
|
||||
fs::remove_file(&file_path).ok();
|
||||
}
|
||||
}
|
||||
STORAGE.delete_media(&path);
|
||||
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "playlist_changed".into(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: 添加 URL =====================
|
||||
async fn add_url_handler(
|
||||
Json(body): Json<UrlBody>,
|
||||
) -> Json<OkDuplicateResponse> {
|
||||
let url = body.url.trim().to_string();
|
||||
if url.is_empty() {
|
||||
return Json(OkDuplicateResponse { ok: false, duplicate: None });
|
||||
}
|
||||
|
||||
let name = url.rsplit('/').next().unwrap_or(&url)
|
||||
.split('?').next().unwrap_or(&url)
|
||||
.to_string();
|
||||
let t = get_media_type(&url).to_string();
|
||||
let is_new = STORAGE.add_url_media(&url, name, t);
|
||||
|
||||
Json(OkDuplicateResponse { ok: true, duplicate: Some(!is_new) })
|
||||
}
|
||||
|
||||
// ===================== Handler: 播放列表 =====================
|
||||
async fn get_playlist() -> Json<PlaylistResponse> {
|
||||
let items = STORAGE.get_playlist();
|
||||
let settings = STORAGE.get_settings();
|
||||
let dir = media_dir();
|
||||
let mut files = Vec::new();
|
||||
|
||||
for item in &items {
|
||||
if item.source.as_deref() == Some("url") {
|
||||
let ext = std::path::Path::new(&item.path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
|
||||
files.push(MediaFile {
|
||||
name: item.name.clone().unwrap_or_else(|| item.path.clone()),
|
||||
relative_path: item.path.clone(),
|
||||
file_type: t.to_string(),
|
||||
url: item.path.clone(),
|
||||
source: "url".into(),
|
||||
});
|
||||
} else {
|
||||
let file_path = dir.join(&item.path);
|
||||
if file_path.exists() {
|
||||
let ext = file_path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
let t = if ["mp4", "mkv", "avi"].contains(&ext) { "video" } else { "image" };
|
||||
files.push(MediaFile {
|
||||
name: file_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&item.path)
|
||||
.to_string(),
|
||||
relative_path: item.path.clone(),
|
||||
file_type: t.to_string(),
|
||||
url: format!("/file/{}", item.path),
|
||||
source: "local".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json(PlaylistResponse {
|
||||
files,
|
||||
volume: settings.volume,
|
||||
play_mode: settings.play_mode,
|
||||
image_duration: settings.image_duration,
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_playlist(
|
||||
Json(body): Json<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
STORAGE.add_to_playlist(&body.path);
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "playlist_changed".into(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
async fn remove_playlist(
|
||||
Json(body): Json<PathBody>,
|
||||
) -> Json<OkResponse> {
|
||||
STORAGE.remove_from_playlist(&body.path);
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "playlist_changed".into(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: 播放控制 =====================
|
||||
async fn control_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ActionBody>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if !["play", "pause", "next", "prev"].contains(&body.action.as_str()) {
|
||||
return Err((StatusCode::BAD_REQUEST, "action 必须是 play/pause/next/prev".into()));
|
||||
}
|
||||
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: body.action.clone(),
|
||||
state: None,
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
|
||||
let mut playback = state.playback.lock().await;
|
||||
playback.status = match body.action.as_str() {
|
||||
"play" => "playing".into(),
|
||||
"pause" => "paused".into(),
|
||||
_ => playback.status.clone(),
|
||||
};
|
||||
let state_snapshot = playback.clone();
|
||||
drop(playback);
|
||||
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "state_update".into(),
|
||||
state: Some(state_snapshot),
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "action": body.action})))
|
||||
}
|
||||
|
||||
// ===================== Handler: 状态 =====================
|
||||
async fn get_state(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<StateResponse> {
|
||||
let s = state.playback.lock().await;
|
||||
Json(StateResponse { state: s.clone() })
|
||||
}
|
||||
|
||||
async fn update_state(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<StateBody>,
|
||||
) -> Json<OkResponse> {
|
||||
{
|
||||
let mut s = state.playback.lock().await;
|
||||
if !body.state.status.is_empty() {
|
||||
s.status = body.state.status.clone();
|
||||
}
|
||||
s.index = body.state.index;
|
||||
if !body.state.name.is_empty() {
|
||||
s.name = body.state.name.clone();
|
||||
}
|
||||
if !body.state.media_type.is_empty() {
|
||||
s.media_type = body.state.media_type.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let s2 = state.playback.lock().await;
|
||||
EVENTS.broadcast(&ServerEvent {
|
||||
action: "state_update".into(),
|
||||
state: Some(s2.clone()),
|
||||
volume: None,
|
||||
play_mode: None,
|
||||
image_duration: None,
|
||||
});
|
||||
|
||||
Json(OkResponse { ok: true })
|
||||
}
|
||||
|
||||
// ===================== Handler: SSE =====================
|
||||
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let rx = EVENTS.subscribe();
|
||||
let stream = BroadcastStream::new(rx).map(|msg| match msg {
|
||||
Ok(data) => Ok(Event::default().data(data)),
|
||||
Err(_) => Ok(Event::default().data("")),
|
||||
});
|
||||
Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(std::time::Duration::from_secs(15)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use crate::models::*;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 数据文件路径:系统数据目录 /dpm/data.json
|
||||
fn db_path() -> PathBuf {
|
||||
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
path.push("dpm");
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
path.push("data.json");
|
||||
path
|
||||
}
|
||||
|
||||
/// 持久化数据结构
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistentData {
|
||||
settings: std::collections::HashMap<String, Settings>,
|
||||
playlist: Vec<PlaylistItem>,
|
||||
url_media: Vec<UrlMediaItem>,
|
||||
}
|
||||
|
||||
impl Default for PersistentData {
|
||||
fn default() -> Self {
|
||||
let mut s = std::collections::HashMap::new();
|
||||
s.insert("admin".into(), Settings::default());
|
||||
Self {
|
||||
settings: s,
|
||||
playlist: Vec::new(),
|
||||
url_media: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 数据存储
|
||||
pub struct Storage {
|
||||
inner: Mutex<PersistentData>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn new() -> Self {
|
||||
let path = db_path();
|
||||
let data = if path.exists() {
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
PersistentData::default()
|
||||
};
|
||||
let storage = Self {
|
||||
inner: Mutex::new(data),
|
||||
path,
|
||||
};
|
||||
storage.save();
|
||||
storage
|
||||
}
|
||||
|
||||
fn save(&self) {
|
||||
if let Ok(data) = self.inner.lock() {
|
||||
if let Ok(json) = serde_json::to_string_pretty(&*data) {
|
||||
std::fs::write(&self.path, json).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Settings ===
|
||||
pub fn get_settings(&self) -> Settings {
|
||||
self.inner
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|d| d.settings.get("admin").cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn update_settings(&self, update: SettingsUpdate) {
|
||||
if let Ok(mut data) = self.inner.lock() {
|
||||
if let Some(s) = data.settings.get_mut("admin") {
|
||||
if let Some(v) = update.volume {
|
||||
s.volume = v;
|
||||
}
|
||||
if let Some(m) = update.play_mode {
|
||||
s.play_mode = m;
|
||||
}
|
||||
if let Some(d) = update.image_duration {
|
||||
s.image_duration = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
|
||||
// === Playlist ===
|
||||
pub fn get_playlist(&self) -> Vec<PlaylistItem> {
|
||||
self.inner
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|d| d.playlist.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn add_to_playlist(&self, path: &str) {
|
||||
if let Ok(mut data) = self.inner.lock() {
|
||||
if !data.playlist.iter().any(|p| p.path == path) {
|
||||
let mut item = PlaylistItem {
|
||||
path: path.into(),
|
||||
source: None,
|
||||
name: None,
|
||||
};
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
item.source = Some("url".into());
|
||||
if let Some(um) = data.url_media.iter().find(|u| u.url == path) {
|
||||
item.name = Some(um.name.clone());
|
||||
}
|
||||
}
|
||||
data.playlist.push(item);
|
||||
}
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
|
||||
pub fn remove_from_playlist(&self, path: &str) {
|
||||
if let Ok(mut data) = self.inner.lock() {
|
||||
data.playlist.retain(|p| p.path != path);
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
|
||||
// === URL Media ===
|
||||
pub fn add_url_media(&self, url: &str, name: String, media_type: String) -> bool {
|
||||
let is_new = if let Ok(mut data) = self.inner.lock() {
|
||||
if !data.url_media.iter().any(|u| u.url == url) {
|
||||
data.url_media.push(UrlMediaItem {
|
||||
url: url.into(),
|
||||
name,
|
||||
media_type,
|
||||
added_at: chrono::Local::now()
|
||||
.format("%Y-%m-%dT%H:%M:%S%.3f")
|
||||
.to_string(),
|
||||
});
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if is_new {
|
||||
self.save();
|
||||
}
|
||||
is_new
|
||||
}
|
||||
|
||||
pub fn get_url_media(&self) -> Vec<UrlMediaItem> {
|
||||
self.inner
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|d| d.url_media.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn remove_url_media(&self, url: &str) {
|
||||
if let Ok(mut data) = self.inner.lock() {
|
||||
data.url_media.retain(|u| u.url != url);
|
||||
}
|
||||
self.save();
|
||||
}
|
||||
|
||||
// === Delete ===
|
||||
pub fn delete_media(&self, path: &str) {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
self.remove_url_media(path);
|
||||
}
|
||||
self.remove_from_playlist(path);
|
||||
}
|
||||
}
|
||||
|
||||
// 全局共享实例
|
||||
pub static STORAGE: Lazy<Storage> = Lazy::new(Storage::new);
|
||||
|
||||
/// 判断文件扩展名是否允许
|
||||
pub fn is_allowed_extension(path: &str) -> bool {
|
||||
let allowed = [".mp4", ".mkv", ".avi", ".jpg", ".jpeg", ".png"];
|
||||
let lower = path.to_lowercase();
|
||||
allowed.iter().any(|ext| lower.ends_with(ext))
|
||||
}
|
||||
|
||||
/// 获取媒体类型
|
||||
pub fn get_media_type(path: &str) -> &'static str {
|
||||
let lower = path.to_lowercase();
|
||||
if lower.ends_with(".mp4") || lower.ends_with(".mkv") || lower.ends_with(".avi") {
|
||||
"video"
|
||||
} else {
|
||||
"image"
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "dpm",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"title": "大屏媒体轮播系统",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
Reference in New Issue
Block a user