commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user