Files
python_script/flask-dev-api/utils/down_video_utils.py
2026-06-16 03:30:57 +08:00

317 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
down-video 视频下载工具函数
"""
import os
import re
import subprocess
from urllib.parse import urlparse
from config import TWITTER_COOKIE, BILIBILI_COOKIE, INSTAGRAM_COOKIE, YOUTUBE_COOKIE
# 可选依赖
try:
import yt_dlp
YTDLP_AVAILABLE = True
except ImportError:
YTDLP_AVAILABLE = False
def check_ffmpeg():
"""检查 FFmpeg 是否可用"""
try:
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
return result.returncode == 0
except Exception:
return False
def check_deno():
"""检查 Deno 是否可用"""
try:
result = subprocess.run(['deno', '--version'], capture_output=True, text=True, timeout=5, shell=True)
if result.returncode == 0:
return True
except Exception:
pass
# fallback: 检查默认安装路径
deno_path = os.path.join(os.path.expanduser('~'), '.deno', 'bin', 'deno.exe')
if os.path.isfile(deno_path):
try:
result = subprocess.run([deno_path, '--version'], capture_output=True, text=True, timeout=5)
return result.returncode == 0
except Exception:
pass
return False
def is_twitter_url(url):
"""检查是否是 Twitter/X 链接"""
domain = urlparse(url).netloc.lower()
return 'x.com' in domain or 'twitter.com' in domain
def is_bilibili_url(url):
"""检查是否是 Bilibili 链接"""
domain = urlparse(url).netloc.lower()
return 'bilibili.com' in domain or 'b23.tv' in domain
def is_instagram_url(url):
"""检查是否是 Instagram 链接"""
domain = urlparse(url).netloc.lower()
return 'instagram.com' in domain
def is_youtube_url(url):
"""检查是否是 YouTube 链接"""
domain = urlparse(url).netloc.lower()
return 'youtube.com' in domain or 'youtu.be' in domain
def _clean_title(title):
"""
清理视频标题,移除特殊字符,使文件名在文件系统中安全可用
"""
if not title:
return "video"
# 移除或替换特殊字符
# Windows 文件名不允许的字符
cleaned = re.sub(r'[<>:"/\\|?*]', '_', title)
# 控制字符
cleaned = re.sub(r'[\x00-\x1f\x7f]', '', cleaned)
# 移除 emoji 和其他 Unicode 特殊字符(保留中文、英文、数字、下划线)
# 匹配非中文、非英文、非数字、非下划线的字符
cleaned = re.sub(r'[^一-鿿㐀-䶿\w]', '_', cleaned)
# 将多个连续下划线合并为一个
cleaned = re.sub(r'_+', '_', cleaned)
# 移除首尾空格和点Windows不允许
cleaned = cleaned.strip('_. ')
# 限制长度(保留扩展名空间)
cleaned = cleaned[:150]
# 如果清理后为空,使用默认名
if not cleaned:
return "video"
return cleaned
def _find_files_by_title(output_dir, safe_title):
"""根据安全标题查找目录中的相关文件"""
return [
f for f in os.listdir(output_dir)
if f.startswith(safe_title) and os.path.isfile(os.path.join(output_dir, f))
]
def _merge_mp4_m4a(output_dir, safe_title):
"""
查找同名的 .mp4 和 .m4a 并用 FFmpeg 合并。
返回 (merged: bool, message: str, filepath: str)
"""
files = _find_files_by_title(output_dir, safe_title)
mp4_files = [f for f in files if f.endswith('.mp4')]
m4a_files = [f for f in files if f.endswith('.m4a')]
if not mp4_files or not m4a_files:
return False, "未检测到需要合并的 .mp4 + .m4a", ""
mp4_files.sort(key=len)
m4a_files.sort(key=len)
mp4_path = os.path.join(output_dir, mp4_files[0])
m4a_path = os.path.join(output_dir, m4a_files[0])
merged_name = safe_title + "_merged.mp4"
merged_path = os.path.join(output_dir, merged_name)
counter = 1
while os.path.exists(merged_path):
merged_name = f"{safe_title}_merged_{counter}.mp4"
merged_path = os.path.join(output_dir, merged_name)
counter += 1
cmd = ['ffmpeg', '-y', '-i', mp4_path, '-i', m4a_path, '-c', 'copy', merged_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0 and os.path.exists(merged_path):
try:
os.remove(mp4_path)
os.remove(m4a_path)
except Exception:
pass
return True, f"已合并为 {merged_name}", merged_path
else:
return False, f"FFmpeg 合并失败: {result.stderr[:200]}", ""
except Exception as e:
return False, f"合并异常: {str(e)}", ""
class DownloadProgressLogger:
"""捕获yt-dlp下载进度的回调类"""
def __init__(self, progress_callback=None):
self.progress_callback = progress_callback
def debug(self, msg):
pass
def warning(self, msg):
pass
def error(self, msg):
if self.progress_callback:
self.progress_callback({'type': 'error', 'message': msg})
def info(self, msg):
if self.progress_callback:
self.progress_callback({'type': 'info', 'message': msg})
def download_progress(self, d):
if d['status'] == 'downloading' and self.progress_callback:
percent = d.get('_percent_str', '0%').replace('%', '').strip()
try:
percent = float(percent)
except ValueError:
percent = 0
speed = d.get('_speed_str', 'N/A')
eta = d.get('_eta_str', 'N/A')
filename = d.get('filename', '').split('\\')[-1].split('/')[-1]
self.progress_callback({
'type': 'progress',
'percent': percent,
'speed': speed,
'eta': eta,
'filename': filename
})
elif d['status'] == 'finished' and self.progress_callback:
self.progress_callback({
'type': 'progress',
'percent': 100,
'speed': '',
'eta': '',
'filename': ''
})
def download_video(video_url, output_dir, platform, progress_callback=None, proxy_url=None):
"""
通用下载函数,返回 (success: bool, message: str, title: str, filepath: str)
支持进度回调
"""
if not YTDLP_AVAILABLE:
return False, "yt-dlp 未安装,视频下载功能不可用。", "", ""
os.makedirs(output_dir, exist_ok=True)
# 先提取视频信息以获取标题
temp_opts = {
'quiet': True,
'no_warnings': True,
'skip_download': True,
}
if platform == 'twitter':
cookie_file = TWITTER_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
elif platform == 'bilibili':
cookie_file = BILIBILI_COOKIE
elif platform == 'instagram':
cookie_file = INSTAGRAM_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
elif platform == 'youtube':
cookie_file = YOUTUBE_COOKIE
if proxy_url:
temp_opts['proxy'] = proxy_url
else:
return False, "不支持的平台", "", ""
if os.path.exists(cookie_file):
temp_opts['cookiefile'] = cookie_file
else:
return False, f"Cookie 文件 '{cookie_file}' 未找到", "", ""
try:
# 创建进度日志器
progress_logger = DownloadProgressLogger(progress_callback)
# 获取视频信息
with yt_dlp.YoutubeDL(temp_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
if not info:
return False, "未能获取视频信息", "", ""
# 清理标题作为安全文件名
title = info.get('title', 'Unknown Title')
safe_title = _clean_title(title)
# 使用清理后的标题构建输出模板
ydl_opts = {
'outtmpl': os.path.join(output_dir, f'{safe_title}.%(ext)s'),
'ignoreerrors': True,
'progress_hooks': [progress_logger.download_progress] if progress_callback else [],
'logger': progress_logger if progress_callback else None,
'format': 'bestvideo+bestaudio/best',
}
if platform == 'twitter':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
ydl_opts['postprocessors'] = [{
'key': 'FFmpegVideoConvertor',
'preferedformat': 'mp4',
}]
elif platform == 'bilibili':
ydl_opts['merge_output_format'] = 'mp4'
elif platform == 'instagram':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
elif platform == 'youtube':
if proxy_url:
ydl_opts['proxy'] = proxy_url
ydl_opts['merge_output_format'] = 'mp4'
if os.path.exists(cookie_file):
ydl_opts['cookiefile'] = cookie_file
# 执行下载
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([video_url])
# 查找下载的文件
files = _find_files_by_title(output_dir, safe_title)
if not files:
return False, "下载完成但未找到输出文件", title, ""
filepath = ""
candidate_files = [f for f in files if f.endswith('.mp4')]
if candidate_files:
merged_candidates = [f for f in candidate_files if '_merged' in f]
if merged_candidates:
filepath = os.path.join(output_dir, merged_candidates[0])
else:
candidate_files.sort(key=lambda f: os.path.getsize(os.path.join(output_dir, f)), reverse=True)
filepath = os.path.join(output_dir, candidate_files[0])
else:
files.sort(key=lambda f: os.path.getsize(os.path.join(output_dir, f)), reverse=True)
filepath = os.path.join(output_dir, files[0])
extra_msg = ""
if platform in ('bilibili', 'youtube'):
has_mp4 = any(f.endswith('.mp4') for f in files)
has_m4a = any(f.endswith('.m4a') for f in files)
if has_mp4 and has_m4a:
merged, merge_msg, merged_path = _merge_mp4_m4a(output_dir, safe_title)
extra_msg = f" ({merge_msg})" if merge_msg else ""
if merged and merged_path:
filepath = merged_path
return True, f"下载成功!保存到 {output_dir}{extra_msg}", title, filepath
except Exception as e:
return False, f"下载失败: {str(e)}", "", ""