效果
文件结构
飞牛影视
实现
metube负责监控播放列表或者频道
飞牛影视里可以看到封面、简介、发布日期
视频目录结构是: 频道 - 发布时间_视频标题 - 视频.mp4
最耗时bug
飞牛影视读取的简介不能有连续的回车换行....用代码删了
每次修改metube的json和py后,都要完全卸载飞牛影视后重装(存疑,可能和自动扫描媒体库有关,不想搞明白了,就这样吧)所以现在也关了自动扫描媒体库,避免简介读取不了
步骤(ChatGPT写的)
网络:mihomo+metacubexd tun模式
(感谢李田所的分享)
(metacubexd的镜像下载好慢,先用了v2rayA挂了http代理给飞牛docker下载)
metube
docker-compose.yml
services:
metube:
image: ghcr.io/alexta69/metube:latest
container_name: metube
restart: unless-stopped
ports:
- "8081:8081"
volumes:
# 下载好的 YouTube 视频
- /vol1/1000/YouTube:/downloads
# yt-dlp 配置文件
- /vol1/1000/metube/json/ytdl-options.json:/config/ytdl-options.json:ro
# 自动生成 NFO 的脚本
- /vol1/1000/metube/json/create_nfo.py:/config/create_nfo.py:ro
environment:
- DOWNLOAD_DIR=/downloads
- YTDL_OPTIONS_FILE=/config/ytdl-options.json
ytdl-options.json
放在vol1/1000/metube/json/
{
"writethumbnail": true,
"writeinfojson": true,
"writedescription": true,
"outtmpl": {
"default": "%(channel|YouTube).120B/%(upload_date>%Y_%m_%d)s_%(title).150B/%(title).150B.%(ext)s",
"thumbnail": "%(channel|YouTube).120B/%(upload_date>%Y_%m_%d)s_%(title).150B/poster.%(ext)s",
"infojson": "%(channel|YouTube).120B/%(upload_date>%Y_%m_%d)s_%(title).150B/metadata.info.json",
"description": "%(channel|YouTube).120B/%(upload_date>%Y_%m_%d)s_%(title).150B/description.txt"
},
"postprocessors": [
{
"key": "FFmpegThumbnailsConvertor",
"format": "jpg"
},
{
"key": "Exec",
"when": "after_move",
"exec_cmd": "python3 /config/create_nfo.py %(filepath)q"
}
]
}
create_nfo.py
放在vol1/1000/metube/json/
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
import xml.etree.ElementTree as ET
def clean_xml_text(value):
if value is None:
return ""
text = str(value)
return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", text)
def normalize_description_newlines(desc):
"""
飞牛对简介里的连续空行兼容不好。
这里把连续两个及以上换行压缩成一个换行。
"""
desc = desc or ""
# 统一 Windows / Mac / Linux 换行
desc = desc.replace("\r\n", "\n").replace("\r", "\n")
# 去掉每一行首尾多余空格
lines = [line.strip() for line in desc.split("\n")]
desc = "\n".join(lines)
# 连续两个及以上换行,压缩成一个换行
desc = re.sub(r"\n{2,}", "\n", desc)
return desc.strip()
def ymd(value):
if not value:
return ""
value = str(value)
if re.fullmatch(r"\d{8}", value):
return f"{value[:4]}-{value[4:6]}-{value[6:8]}"
return value
def add(parent, tag, value):
value = clean_xml_text(value)
if value == "":
return None
elem = ET.SubElement(parent, tag)
elem.text = value
return elem
def add_publish_date_to_description(desc, upload_date):
desc = desc or ""
# 先规范换行,避免原简介本身有连续空行
desc = normalize_description_newlines(desc)
# 防止重复运行脚本时,简介开头重复添加发布日期
desc = re.sub(
r"^发布日期[::]\s*\d{4}(?:-\d{2}-\d{2})?\s*\n*",
"",
desc
).lstrip()
# 删除后再规范一次,避免删除发布日期后留下空行
desc = normalize_description_newlines(desc)
if not upload_date:
return desc
if desc:
desc = f"发布日期:{upload_date}\n{desc}"
else:
desc = f"发布日期:{upload_date}"
# 最后再压缩一次,确保绝对不会出现连续两个及以上换行
return normalize_description_newlines(desc)
def main():
if len(sys.argv) < 2:
print("No video path provided")
return 0
video_path = Path(sys.argv[-1])
if not video_path.exists():
print(f"Video not found: {video_path}")
return 0
folder = video_path.parent
info_json = folder / "metadata.info.json"
if not info_json.exists():
matches = list(folder.glob("*.info.json"))
if matches:
info_json = matches[0]
else:
print(f"No info json found in: {folder}")
return 0
with info_json.open("r", encoding="utf-8") as f:
data = json.load(f)
desc = data.get("description") or ""
desc_file = folder / "description.txt"
if not desc and desc_file.exists():
desc = desc_file.read_text(encoding="utf-8", errors="ignore")
title = data.get("title") or video_path.stem
channel = data.get("channel") or data.get("uploader") or ""
upload_date = ymd(data.get("upload_date") or data.get("release_date"))
year = data.get("release_year") or (upload_date[:4] if upload_date else "")
duration = data.get("duration")
video_id = data.get("id") or ""
webpage_url = data.get("webpage_url") or data.get("original_url") or ""
# 在简介开头增加发布日期,并清理连续空行
desc = add_publish_date_to_description(desc, upload_date)
# 同步改写 description.txt,确保 description.txt 里也没有连续空行
if desc:
desc_file.write_text(desc, encoding="utf-8")
root = ET.Element("movie")
add(root, "title", title)
add(root, "originaltitle", title)
add(root, "sorttitle", title)
add(root, "plot", desc)
add(root, "outline", desc.splitlines()[0][:200] if desc else "")
add(root, "premiered", upload_date)
add(root, "aired", upload_date)
add(root, "year", year)
if duration:
try:
add(root, "runtime", str(round(float(duration) / 60)))
except Exception:
pass
add(root, "studio", channel)
add(root, "director", channel)
add(root, "website", webpage_url)
if video_id:
unique = ET.SubElement(root, "uniqueid", {"type": "youtube", "default": "true"})
unique.text = clean_xml_text(video_id)
for category in data.get("categories") or []:
add(root, "genre", category)
for tag in (data.get("tags") or [])[:20]:
add(root, "tag", tag)
add(root, "thumb", "poster.jpg")
fanart = ET.SubElement(root, "fanart")
fanart_thumb = ET.SubElement(fanart, "thumb")
fanart_thumb.text = "poster.jpg"
nfo_path = video_path.with_suffix(".nfo")
tree = ET.ElementTree(root)
if hasattr(ET, "indent"):
ET.indent(tree, space=" ")
tree.write(
nfo_path,
encoding="utf-8",
xml_declaration=True,
short_empty_elements=False
)
print(f"NFO created: {nfo_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())