This commit is contained in:
尹舟 2025-03-16 19:03:36 +08:00
parent 8955853a68
commit 27be793e08
14 changed files with 368 additions and 91 deletions

View File

@ -1,5 +1,4 @@
.venv/
.idea/
.deploy/
logs/
./mp4/*

View File

@ -8,7 +8,7 @@ services:
container_name: m3u8_download
image: registry.cn-hangzhou.aliyuncs.com/yinzhou_docker_hub/m3u8_download:latest
ports:
- "1314:1314"
- "8778:8778"
volumes:
- ./mp4:/opt/m3u8_download/mp4

View File

@ -17,4 +17,7 @@ RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua
COPY . .
# 运行应用程序
ENTRYPOINT ["python3", "m3u8_download.py"]
# ENTRYPOINT ["python3", "m3u8_download.py"]
CMD ["sh", "-c", "python3 m3u8_download.py & python3 m3u8_ui.py & tail -f logs/m3u8_download.log"]

View File

@ -1,46 +1,9 @@
import m3u8_to_mp4
from utils.MySqlUtil import MySqlUtil
from apscheduler.schedulers.blocking import BlockingScheduler
from utils.Download import download_m3u8
import time
from utils.Log import Log
from pathlib import Path
def download_m3u8(download_path='./mp4/'):
try:
log = Log().getlog()
# 初始化数据库连接
movie_config = MySqlUtil("movie")
# 获取未处理的电影记录
movie_message = MySqlUtil.get_one(movie_config, 'SELECT * FROM `movie` WHERE is_ok=0 LIMIT 1')
if not movie_message or len(movie_message) < 3: # 校验结果是否有效
log.info("没有找到电影记录或无效数据。")
return
id, name, url = movie_message[0], movie_message[1], movie_message[2]
# 构造目标文件路径
file_path = Path(download_path).joinpath(f"{name}.mp4")
# 更新数据库状态,使用参数化查询防止 SQL 注入
sql = f'UPDATE `movie`.`movie` SET `is_ok` = 1 WHERE `id` = {id}'
MySqlUtil.update(movie_config, sql=sql)
log.info(f"任务下载中,正在下载 {name}...")
# 下载 m3u8 文件并转换为 MP4
m3u8_to_mp4.multithread_download(url, file_path=str(file_path))
log.info(f"成功下载并转换 {name} to {file_path}.")
except Exception as e:
log.error(f"下载过程中出现错误: {e}")
if __name__ == '__main__':
download_m3u8()
# str_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
# sch = BlockingScheduler(timezone='Asia/Shanghai')
# sch.add_job(download_m3u8, 'cron', minute='*/2')
# sch.start()
# 循环执行download_m3u8() 在执行完后休眠1分钟
while True:
download_m3u8()
time.sleep(60)

85
m3u8_ui.py Normal file
View File

@ -0,0 +1,85 @@
from flask import Flask, render_template, request, jsonify, send_file, current_app
from flask_cors import CORS # 导入 Flask-CORS 扩展
from utils.Log import Log
from utils.Db_Execute import get_movie_list,movie_options,movie_add
from werkzeug.exceptions import BadRequest
app = Flask(__name__)
log = Log()
# 允许所有源访问接口,可以根据需要进行更细粒度的控制
CORS(app)
@app.route('/')
def index():
return send_file('templates/index.html')
@app.route('/movie_list', methods=['GET'])
def convert_sql():
try:
# 调用 get_movie_list() 获取电影列表
movie_list = get_movie_list()
# 如果列表为空,返回空数组
if not movie_list:
return jsonify([]), 200
# 正常返回电影列表
return jsonify(movie_list), 200
except Exception as e:
# 捕获异常并返回错误信息
log.error(f"Error in fetching movie list: {str(e)}", exc_info=True) # 添加 exc_info=True 以便记录完整的堆栈跟踪
return jsonify({"error": "Failed to fetch movie list"}), 500
@app.route('/movie/<int:movie_id>', methods=['GET', 'PUT'])
def movie_markers(movie_id):
try:
if request.method == 'GET':
# 获取当前状态逻辑(示例)
current_status = movie_options(movie_id) # 替换为实际数据库查询
return jsonify({"status": current_status}), 200
elif request.method == 'PUT':
# 更新状态逻辑(示例)
movie_options(movie_id) # 替换为实际数据库操作
return jsonify(success=True), 200
else:
return jsonify({"message": "无效的请求方法"}), 400
except Exception as e:
return jsonify({"message": "操作失败", "error": str(e)}), 500
@app.route('/add_movie', methods=['POST'])
def add_movie():
try:
# 修改点1获取JSON格式数据
data = request.get_json()
if not data:
raise BadRequest("请求体必须为JSON格式")
# 修改点2使用正确的字段名
movie_name = data.get('name')
movie_path = data.get('path')
# 输入验证(字段名同步修改)
if not movie_name or not movie_path:
raise BadRequest("缺少必要的参数: name 或 path")
if len(movie_name) > 255 or len(movie_path) > 255:
raise BadRequest("参数过长: name 或 path 超过255字符")
# 数据库操作(保持原逻辑)
movie_add(movie_name, movie_path)
return jsonify(success=True), 200
except BadRequest as e:
return jsonify({"message": e.description}), 400
except Exception as e:
return jsonify({"message": "操作失败", "error": str(e)}), 500
if __name__ == '__main__':
# 指定host和port这里使用0.0.0.0可以让服务器被外部访问
app.run(host='0.0.0.0', port=8778, debug=True)

View File

@ -3,4 +3,6 @@ m3u8>=0.9.0
pycryptodome>=3.10.1
pandas
pymysql
apscheduler
apscheduler
flask
flask_cors

BIN
sqlite/movies.db Normal file

Binary file not shown.

125
templates/index.html Normal file
View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>电影下载管理系统</title>
<link rel="icon" href="https://yinzhou.oss-cn-shanghai.aliyuncs.com/image/chaiquan.png">
<style>
/* 保持原有CSS样式不变 */
body {font-family: Arial, sans-serif; max-width: 1000px; margin: 20px auto; padding: 20px; background-color: #f5f5f5;}
.container {background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);}
table {width: 100%; border-collapse: collapse; margin: 20px 0;}
th, td {padding: 12px; text-align: left; border-bottom: 1px solid #ddd;}
th {background-color: #f8f9fa;}
.status-0 {color: #dc3545;}
.status-1 {color: #28a745;}
button {padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;}
button:hover {background-color: #0056b3;}
.dialog {display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);}
.dialog input {display: block; margin: 10px 0; padding: 8px; width: 300px;}
.loading-mask {position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.8); display: none; justify-content: center; align-items: center;}
</style>
</head>
<body>
<div class="container">
<h1>电影下载列表</h1>
<button onclick="showAddDialog()">新增记录</button>
<table id="movieTable">
<thead><tr><th>ID</th><th>电影名称</th><th>m3u8地址</th><th>下载状态</th><th>操作</th></tr></thead>
<tbody id="movieList"></tbody>
</table>
</div>
<div id="addDialog" class="dialog">
<h3>添加新电影</h3>
<input type="text" id="movieName" placeholder="输入电影名称">
<input type="text" id="moviePath" placeholder="输入m3u8地址">
<div style="text-align: right; margin-top: 15px;">
<button onclick="addNewMovie()">确认添加</button>
<button onclick="hideAddDialog()" style="background-color: #6c757d; margin-left: 8px;">取消</button>
</div>
</div>
<div class="loading-mask" id="loading">更新中...</div>
<script>
// 动态获取当前域名和协议
const API_BASE = `${window.location.protocol}//${window.location.hostname}:8778`;
// 页面加载时获取数据
window.onload = async () => {
try {
const response = await fetch(`${API_BASE}/movie_list?_=${Date.now()}`);
if (!response.ok) throw new Error('HTTP错误');
const movies = await response.json();
renderMovieList(movies);
} catch (error) {
console.error('数据加载失败:', error);
alert('无法加载电影列表,请检查服务器连接');
}
};
// 渲染电影列表
function renderMovieList(movies) {
const tbody = document.getElementById('movieList');
tbody.innerHTML = '';
movies.forEach(movie => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${movie[0]}</td>
<td>${movie[1]}</td>
<td>${movie[2]}</td>
<td class="status-${movie[3]}">${movie[3] === 0 ? '未下载' : '已下载'}</td>
<td><button onclick="toggleDownloadStatus(${movie[0]})">${movie[3] === 0 ? '标记为已下载' : '标记为未下载'}</button></td>`;
tbody.appendChild(row);
});
}
// 对话框控制
function showAddDialog() { document.getElementById('addDialog').style.display = 'block'; }
function hideAddDialog() { document.getElementById('addDialog').style.display = 'none'; document.getElementById('movieName').value = ''; document.getElementById('moviePath').value = ''; }
// 新增电影
async function addNewMovie() {
const name = document.getElementById('movieName').value.trim();
const path = document.getElementById('moviePath').value.trim();
if (!name || !path) return alert('请填写完整信息');
try {
document.getElementById('loading').style.display = 'flex';
const response = await fetch(`${API_BASE}/add_movie`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path})
});
if (!response.ok) throw new Error('添加失败');
const updated = await fetch(`${API_BASE}/movie_list?_=${Date.now()}`);
if (!updated.ok) throw new Error('刷新数据失败');
renderMovieList(await updated.json());
hideAddDialog();
} catch (error) { alert(error.message); }
finally { document.getElementById('loading').style.display = 'none'; }
}
// 切换下载状态
async function toggleDownloadStatus(id) {
try {
const statusRes = await fetch(`${API_BASE}/movie/${id}?_=${Date.now()}`);
const {status} = await statusRes.json();
const updateRes = await fetch(`${API_BASE}/movie/${id}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_download: status === 0 ? 1 : 0})
});
if (updateRes.ok) {
const updatedRes = await fetch(`${API_BASE}/movie_list?_=${Date.now()}`);
renderMovieList(await updatedRes.json());
}
} catch (error) { console.error('操作失败:', error); }
}
</script>
</body>
</html>

56
utils/Db_Execute.py Normal file
View File

@ -0,0 +1,56 @@
# -*- coding:utf-8 -*-
import os
from utils.SQLiteDB import SQLiteDB
from utils.MySqlUtil import MySqlUtil
def get_config():
mode= os.environ.get('mode1',1)
if mode==1:
return 'sqlite'
else:
return 'mysql'
def get_movie_list():
if get_config()=='sqlite':
db = SQLiteDB()
db.connect()
movie_list = db.get_undownloaded()
db.close()
return movie_list
else:
db = MySqlUtil('movies')
movie_list = db.get_all('select * from movies where is_downloaded=0')
return movie_list
def movie_options(movie_id):
if get_config()=='sqlite':
db = SQLiteDB()
db.connect()
result=db.mark_downloaded(movie_id)
db.close()
return result
else:
db = MySqlUtil('movies')
movie_list = db.get_all('select * from movies where is_downloaded=0')
return movie_list
def movie_add(movie_name,movie_path):
if get_config()=='sqlite':
db = SQLiteDB()
db.connect()
result=db.insert_movie(movie_name,movie_path)
db.close()
return result
else:
db = MySqlUtil('movies')
movie_list = db.get_all('select * from movies where is_downloaded=0')
return movie_list
if __name__ == '__main__':
print(get_movie_list())

52
utils/Download.py Normal file
View File

@ -0,0 +1,52 @@
import m3u8_to_mp4
from utils.MySqlUtil import MySqlUtil
from apscheduler.schedulers.blocking import BlockingScheduler
import time
from utils.Log import Log
from pathlib import Path
from utils.Db_Execute import get_movie_list, movie_options, movie_add
import os
log = Log()
def download_m3u8():
try:
current_directory = os.path.dirname(os.path.abspath(__file__))
root_path = os.path.abspath(os.path.dirname(current_directory) + os.path.sep + ".")
project_name = root_path.split(os.path.sep)[-1]
project_root_path = os.path.abspath(os.path.dirname(__file__)).split(project_name)[0] + project_name + '/mp4/'
# 调用 get_movie_list() 获取电影列表
movie_list = get_movie_list()
for movie in movie_list:
if not movie or len(movie) < 3: # 校验结果是否有效
log.info("没有找到电影记录或无效数据。")
return
id, name, url = movie[0], movie[1], movie[2]
# 构造目标文件路径
file_path = Path(project_root_path).joinpath(f"{name}.mp4")
# 更新数据库状态,使用参数化查询防止 SQL 注入
movie_options(id)
log.info(f"任务下载中,正在下载 {name}...")
log.info(file_path)
# 下载 m3u8 文件并转换为 MP4
m3u8_to_mp4.multithread_download(url, file_path=file_path)
log.info(f"成功下载并转换 {name} to {file_path}.")
except Exception as e:
log.error(f"下载过程中出现错误: {e}")
if __name__ == '__main__':
download_m3u8()
# str_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
# sch = BlockingScheduler(timezone='Asia/Shanghai')
# sch.add_job(download_m3u8, 'cron', minute='*/2')
# sch.start()

View File

@ -1,5 +1,5 @@
import json, os
from utils.Log import log
from utils.Log import Log
current_directory = os.path.dirname(os.path.abspath(__file__))
root_path = os.path.abspath(os.path.dirname(current_directory) + os.path.sep + ".")
@ -9,7 +9,7 @@ project_name = root_path.split(os.path.sep)[-1]
project_root_path = os.path.abspath(os.path.dirname(__file__)).split(project_name)[0] + project_name
# log.info(str(project_root_path))
log = Log()
def loadconfig(config_key):

View File

@ -2,10 +2,6 @@ import logging
import os
from datetime import datetime
# 定义全局变量 log_path
cur_path = os.path.dirname(os.path.realpath(__file__))
log_path = os.path.join(os.path.dirname(cur_path), 'logs')
class Log():
def __init__(self, logger_name='my_logger'):
@ -14,24 +10,26 @@ class Log():
self.logger.handlers.clear()
self.logger.setLevel(logging.INFO)
if not os.path.exists(log_path):
os.makedirs(log_path)
# 定义固定日志路径
self.log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
self.log_file = os.path.join(self.log_dir, 'm3u8_download.log')
self.update_log_file()
if not os.path.exists(self.log_dir):
os.makedirs(self.log_dir)
def update_log_file(self):
current_date = datetime.now().strftime("%Y_%m_%d")
self.log_name = os.path.join(log_path, f'{current_date}.log')
self._setup_handlers()
for handler in self.logger.handlers[:]:
self.logger.removeHandler(handler)
fh = logging.FileHandler(self.log_name, 'a', encoding='utf-8')
def _setup_handlers(self):
"""初始化日志处理器"""
# 文件处理器(固定文件名)
fh = logging.FileHandler(self.log_file, 'a', encoding='utf-8')
fh.setLevel(logging.INFO)
# 控制台处理器
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
# 统一格式器
formatter = logging.Formatter(
'[%(asctime)s] %(filename)s line:%(lineno)d [%(levelname)s]%(message)s',
datefmt="%Y-%m-%d %H:%M:%S"
@ -39,28 +37,19 @@ class Log():
fh.setFormatter(formatter)
ch.setFormatter(formatter)
# 添加处理器
self.logger.addHandler(fh)
self.logger.addHandler(ch)
def getlog(self):
current_date = datetime.now().strftime("%Y_%m_%d")
log_date = os.path.basename(self.log_name).split('.')[0]
if current_date != log_date:
self.update_log_file()
return self.logger
# 移除日期检查相关方法
def info(self, msg, *args, ** kwargs):
self.logger.info(msg, *args, ** kwargs)
def info(self, msg, *args, **kwargs):
logger = self.getlog()
logger.info(msg, *args, **kwargs)
def error(self, msg, *args, **kwargs):
logger = self.getlog()
logger.error(msg, *args, **kwargs)
def warning(self, msg, *args, **kwargs):
logger = self.getlog()
logger.warning(msg, *args, **kwargs)
def error(self, msg, *args, ** kwargs):
self.logger.error(msg, *args, ** kwargs)
def warning(self, msg, *args, ** kwargs):
self.logger.warning(msg, *args, ** kwargs)
if __name__ == "__main__":
log = Log()

View File

@ -1,12 +1,12 @@
#!/usr/bin/python
# -*- coding:utf-8 -*-
import pymysql
from utils.Log import log
import os
from utils.Log import Log
import platform
import pandas as pd
from utils.LoadConfig import loadconfig
log = Log()
class MySQLError(Exception):
def __init__(self, message):
@ -14,13 +14,14 @@ class MySQLError(Exception):
class MySqlUtil:
"""mysql util"""
db = None
cursor = None
def get_section(db_name):
"""根据系统环境变量获取section"""
platform_ = platform.system()
if platform_ == "Windows" or platform_ == "Darwin":
section = db_name + '_test'

View File

@ -1,10 +1,14 @@
import sqlite3
from typing import Optional, List, Tuple, Any
import os
class SQLiteDB:
def __init__(self, db_path: str = "./sqlite/movies.db"):
self.db_path = db_path
def __init__(self, ):
current_directory = os.path.dirname(os.path.abspath(__file__))
root_path = os.path.abspath(os.path.dirname(current_directory) + os.path.sep + ".")
project_name = root_path.split(os.path.sep)[-1]
project_root_path = os.path.abspath(os.path.dirname(__file__)).split(project_name)[0] + project_name+'/sqlite/movies.db'
self.db_path = project_root_path
self.conn: Optional[sqlite3.Connection] = None
self.cursor: Optional[sqlite3.Cursor] = None
self._init_db()
@ -28,7 +32,7 @@ class SQLiteDB:
"""建立数据库连接基于网页2、网页5的连接方式[2,5](@ref)"""
try:
self.conn = sqlite3.connect(self.db_path)
self.cursor = self.ursor.cursor()
self.cursor = self.conn.cursor() # 修正拼写错误
self.conn.execute("PRAGMA foreign_keys = ON") # 启用外键约束
except sqlite3.Error as e:
raise ConnectionError(f"数据库连接失败: {e}")
@ -101,10 +105,8 @@ if __name__ == "__main__":
with SQLiteDB() as db:
# 批量插入演示集成网页5的executemany方法[5](@ref)
movies = [("泰坦尼克号", "/movies/titanic"), ("阿凡达", "/movies/avatar")]
db.executemany(
"INSERT INTO movie (name, path) VALUES (?, ?)",
movies
)
db.insert_movie("泰坦尼克号","/movies/titanic")
# 查询未下载记录基于网页6的查询模式[6](@ref)
print("待下载电影:", db.get_undownloaded())