文件
wangchuanli efc80ca1e0 feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
2026-08-24 10:51:18 +08:00

211 行
6.8 KiB
Python

#!/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)