This commit is contained in:
CrazyJasonwell
2026-10-07 11:07:00 +08:00
parent e88e4de0f5
commit 290ff8984f
96 changed files with 213854 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
watcher.py —— 自研媒体刮削「监听服务」
功能:
1. 用 watchdog 监听「清理后媒体目录」
2. 检测到新媒体文件/目录写入稳定后,自动调用 media_scraper 刮削
3. 刮削结果(媒体 + nfo + 海报)复制落库到成品目录
4. 源目录保持原样,不写入任何文件
用法:
./watcher.py --config config.json # 常驻服务
./watcher.py --config config.json --once # 只扫描一次现有内容后退出(用于初始化存量)
config.json 字段:
{
"source": "<清理后媒体目录>",
"dest": "<成品落库目录>",
"tmdb_api_key": "", # TMDB API Key;留空时用 --mock 跑通管线
"mock": true, # true=模拟元数据(无Key验证用);true后配好Key改为 false
"settle": 6, # 写入稳定等待秒数
"language": "zh-CN"
}
"""
import argparse
import json
import os
import sys
import threading
import time
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
# 复用核心刮削器
from media_scraper import (Config, Scraper, clean_ext, VIDEO_EXTS,
scan_and_scrape)
class Config2(Config):
"""扩展 Config:加载 watcher 的 source/dest/settle。"""
def __init__(self, path):
super().__init__(path)
data = json.load(open(path, encoding="utf-8"))
self.source = os.path.abspath(data.get("source", ""))
self.dest = os.path.abspath(data.get("dest", ""))
self.settle = int(data.get("settle", 6))
self.mock = data.get("mock", False)
if self.mock:
self.tmdb_api_key = "" # mock 模式不需要 key
class ScraperWorker:
"""把新媒体单元交给刮削器,带去重与写入稳定判定。"""
def __init__(self, cfg, log):
self.cfg = cfg
self.log = log
self.scraper = Scraper(cfg, cfg.dest, source_root=cfg.source, log=log)
self.processed = set() # 已处理的绝对路径
self.lock = threading.Lock()
os.makedirs(cfg.dest, exist_ok=True)
def _is_stable(self, path):
"""判断文件/目录是否写入稳定:两次采样大小一致,最多等 settle 秒。"""
def size(p):
if os.path.isfile(p):
return os.path.getsize(p)
# 目录:取所有文件大小之和
try:
return sum(os.path.getsize(os.path.join(r, f))
for r, _, fs in os.walk(p) for f in fs)
except OSError:
return -1
last = size(path)
wait = self.cfg.settle
while wait > 0:
time.sleep(min(wait, 2))
wait -= 2
cur = size(path)
if cur == last and cur >= 0:
return True
last = cur
return True
def handle(self, path):
path = os.path.abspath(path)
if not os.path.exists(path):
return
key = path if os.path.isdir(path) else os.path.dirname(path)
with self.lock:
if key in self.processed:
return
self.processed.add(key)
try:
self.log(f"检测到新媒体: {path},等待写入稳定…")
self._is_stable(path)
self.log(f"开始刮削: {path}")
scan_and_scrape(path, self.cfg.dest, self.scraper)
self.log(f"完成: {path}")
except Exception as e:
self.log(f"处理失败 {path}: {e}")
class MediaHandler(FileSystemEventHandler):
def __init__(self, worker):
self.worker = worker
self._threads = []
self.lock = threading.Lock()
def _dispatch(self, path):
if os.path.basename(path).startswith("."): # 隐藏/临时文件
return
ext = clean_ext(path)
if ext in VIDEO_EXTS or os.path.isdir(path):
t = threading.Thread(target=self.worker.handle, args=(path,),
daemon=True)
t.start()
with self.lock:
self._threads.append(t)
self._threads = [x for x in self._threads if x.is_alive()]
def on_created(self, event):
self._dispatch(event.src_path)
def on_moved(self, event):
# 下载工具常见:先 .part 再改名成正式名,此时事件是 moved
if not event.dest_path.endswith(".part"):
self._dispatch(event.dest_path)
def on_closed(self, event):
pass
def main():
ap = argparse.ArgumentParser(description="自研媒体刮削监听服务")
ap.add_argument("--config", required=True, help="config.json 路径")
ap.add_argument("--once", action="store_true",
help="扫描现有内容一次后退出(初始化存量)")
args = ap.parse_args()
cfg = Config2(args.config)
if not cfg.source or not os.path.isdir(cfg.source):
print(f"配置错误:source 不存在或未设置 -> {cfg.source}")
sys.exit(1)
def log(msg):
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line)
# 追加写日志文件
logfile = os.path.join(cfg.dest, "..", "scraper.log")
try:
with open(logfile, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
worker = ScraperWorker(cfg, log)
# 启动时先扫描一次现有内容(这样重启服务能补处理之前漏掉的媒体)
for entry in os.listdir(cfg.source):
p = os.path.join(cfg.source, entry)
if os.path.isdir(p) or clean_ext(p) in VIDEO_EXTS:
worker.handle(p)
if args.once:
log("存量扫描完成,退出")
return
# 常驻监听
handler = MediaHandler(worker)
observer = Observer()
observer.schedule(handler, cfg.source, recursive=False)
observer.start()
log(f"开始监听: {cfg.source} -> 成品目录: {cfg.dest}")
try:
while True:
time.sleep(2)
except KeyboardInterrupt:
observer.stop()
observer.join()
if __name__ == "__main__":
main()