飞牛metube容器自动下载YouTube视频,并自动匹配封面和简介

Arrow 2026-07-10 18:09 1

效果


文件结构



飞牛影视



实现


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())
最新回复 (1)
  • Arrow 楼主 07-10 18:10
    1

    本来加自动下载字幕的,后来一直报错,就删了,就这样吧

* 帖子来源NodeSeek
返回