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/ .venv/
.idea/ .idea/
.deploy/ .deploy/
logs/
./mp4/* ./mp4/*

View File

@ -8,7 +8,7 @@ services:
container_name: m3u8_download container_name: m3u8_download
image: registry.cn-hangzhou.aliyuncs.com/yinzhou_docker_hub/m3u8_download:latest image: registry.cn-hangzhou.aliyuncs.com/yinzhou_docker_hub/m3u8_download:latest
ports: ports:
- "1314:1314" - "8778:8778"
volumes: volumes:
- ./mp4:/opt/m3u8_download/mp4 - ./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 . . 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.Download import download_m3u8
from utils.MySqlUtil import MySqlUtil
from apscheduler.schedulers.blocking import BlockingScheduler
import time 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__': if __name__ == '__main__':
download_m3u8() # 循环执行download_m3u8() 在执行完后休眠1分钟
# str_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
# sch = BlockingScheduler(timezone='Asia/Shanghai') while True:
# sch.add_job(download_m3u8, 'cron', minute='*/2') download_m3u8()
# sch.start() 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 pycryptodome>=3.10.1
pandas pandas
pymysql 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 import json, os
from utils.Log import log from utils.Log import Log
current_directory = os.path.dirname(os.path.abspath(__file__)) current_directory = os.path.dirname(os.path.abspath(__file__))
root_path = os.path.abspath(os.path.dirname(current_directory) + os.path.sep + ".") 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 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): def loadconfig(config_key):

View File

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

View File

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

View File

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