feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
这个提交包含在:
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
屏幕播放控制系统 - 后端服务
|
||||
Screen Controller - Backend Server
|
||||
|
||||
一个用于展厅多屏幕定时播放控制的轻量级系统:
|
||||
- 控制端(/)通过 Web 界面管理设备与播放列表
|
||||
- 播放端(/player?device_id=xxx)按配置的时间定时播放图片/视频
|
||||
- 支持局域网内多台设备同时访问
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
render_template,
|
||||
request,
|
||||
jsonify,
|
||||
send_from_directory,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 基础配置
|
||||
# ---------------------------------------------------------------------------
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
|
||||
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||
UPLOAD_DIR = STATIC_DIR # 上传的媒体文件存放目录
|
||||
|
||||
# 允许上传的媒体类型(扩展名 -> 类型)
|
||||
ALLOWED_EXTENSIONS = {
|
||||
"png": "image",
|
||||
"jpg": "image",
|
||||
"jpeg": "image",
|
||||
"gif": "image",
|
||||
"webp": "image",
|
||||
"bmp": "image",
|
||||
"mp4": "video",
|
||||
"webm": "video",
|
||||
"ogg": "video",
|
||||
"mov": "video",
|
||||
"avi": "video",
|
||||
"mkv": "video",
|
||||
}
|
||||
|
||||
# 默认监听地址与端口(0.0.0.0 允许局域网访问)
|
||||
HOST = os.environ.get("HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("PORT", "5000"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置读写
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_config():
|
||||
"""加载配置文件;若不存在则返回默认结构。"""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
if "devices" not in data or not isinstance(data["devices"], list):
|
||||
data["devices"] = []
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"devices": []}
|
||||
return {"devices": []}
|
||||
|
||||
|
||||
def save_config(config):
|
||||
"""保存配置到文件。"""
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=4)
|
||||
|
||||
|
||||
def find_device(config, device_id):
|
||||
"""在配置中查找指定 id 的设备。"""
|
||||
return next((d for d in config.get("devices", []) if d.get("id") == device_id), None)
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""判断文件扩展名是否在允许列表中。"""
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 页面路由
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""控制端:设备管理页面。"""
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/player")
|
||||
@app.route("/player/<device_id>")
|
||||
def player(device_id=None):
|
||||
"""播放端页面。device_id 既可通过路径也可通过查询参数传入。"""
|
||||
return render_template("player.html")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API:配置
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/get_config")
|
||||
def get_config():
|
||||
"""返回完整 JSON 配置。"""
|
||||
return jsonify(load_config())
|
||||
|
||||
|
||||
@app.route("/save_config", methods=["POST"])
|
||||
def save_config_endpoint():
|
||||
"""保存完整配置(来自控制端编辑)。"""
|
||||
try:
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if not isinstance(data, dict) or "devices" not in data:
|
||||
return jsonify({"status": "error", "message": "配置格式不正确"}), 400
|
||||
save_config(data)
|
||||
return jsonify({"status": "success"})
|
||||
except Exception as e: # noqa: BLE001
|
||||
return jsonify({"status": "error", "message": str(e)}), 400
|
||||
|
||||
|
||||
@app.route("/get_playlist/<device_id>")
|
||||
def get_playlist(device_id):
|
||||
"""播放端请求某个设备的播放列表。"""
|
||||
config = load_config()
|
||||
device = find_device(config, device_id)
|
||||
if not device:
|
||||
return jsonify({"error": "Device not found"}), 404
|
||||
# 按 start_time 升序返回,便于前端调度
|
||||
playlist = sorted(
|
||||
device.get("playlist", []),
|
||||
key=lambda x: x.get("start_time", ""),
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"device_id": device_id,
|
||||
"name": device.get("name", device_id),
|
||||
"playlist": playlist,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API:媒体文件
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/upload", methods=["POST"])
|
||||
def upload_file():
|
||||
"""上传媒体文件到 static 目录。"""
|
||||
if "file" not in request.files:
|
||||
return jsonify({"status": "error", "message": "未找到文件字段"}), 400
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
return jsonify({"status": "error", "message": "未选择文件"}), 400
|
||||
if not allowed_file(file.filename):
|
||||
return jsonify({"status": "error", "message": "不支持的文件类型"}), 400
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
# 避免重名覆盖
|
||||
base, ext = os.path.splitext(filename)
|
||||
counter = 1
|
||||
target = os.path.join(UPLOAD_DIR, filename)
|
||||
while os.path.exists(target):
|
||||
filename = f"{base}_{counter}{ext}"
|
||||
target = os.path.join(UPLOAD_DIR, filename)
|
||||
counter += 1
|
||||
|
||||
file.save(target)
|
||||
media_type = ALLOWED_EXTENSIONS[filename.rsplit(".", 1)[1].lower()]
|
||||
return jsonify(
|
||||
{
|
||||
"status": "success",
|
||||
"filename": filename,
|
||||
"path": f"/static/{filename}",
|
||||
"type": media_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/files")
|
||||
def list_files():
|
||||
"""列出 static 目录下可用的媒体文件。"""
|
||||
files = []
|
||||
if os.path.isdir(STATIC_DIR):
|
||||
for name in sorted(os.listdir(STATIC_DIR)):
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||
if ext in ALLOWED_EXTENSIONS:
|
||||
files.append({"name": name, "path": f"/static/{name}", "type": ALLOWED_EXTENSIONS[ext]})
|
||||
return jsonify({"files": files})
|
||||
|
||||
|
||||
@app.route("/static/<path:filename>")
|
||||
def serve_static(filename):
|
||||
"""提供静态媒体文件访问。"""
|
||||
return send_from_directory(STATIC_DIR, filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
# 关闭 debug 以避免生产环境安全隐患;可通过环境变量 FLASK_DEBUG=1 开启
|
||||
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
|
||||
print(f"屏幕控制系统已启动: http://{HOST}:{PORT}/ (局域网内其他设备可访问)")
|
||||
app.run(host=HOST, port=PORT, debug=debug)
|
||||
在新工单中引用
屏蔽一个用户