Files
2026-10-07 11:07:00 +08:00

187 lines
6.1 KiB
Python
Raw Permalink 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.
#!/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()