add --service background daemon with start/stop, configurable interval, and share management

This commit is contained in:
Justin Oro
2026-08-26 22:21:39 -07:00
parent 833c84a283
commit 981f4bf40e
3 changed files with 424 additions and 2 deletions
+20
View File
@@ -151,11 +151,31 @@ python plex-library-tool.py -r "/path/to/your/Movies" -t
| `--backup` | Snapshot current names to a log file without changing anything. | | `--backup` | Snapshot current names to a log file without changing anything. |
| `--restore [LOGFILE]` | Undo a previous run using its log file. Pick from a list if no file is given. | | `--restore [LOGFILE]` | Undo a previous run using its log file. Pick from a list if no file is given. |
| `-u`, `--undo` | Instantly undo the most recent run, no need to look up a log filename. Can't be combined with any other flag. | | `-u`, `--undo` | Instantly undo the most recent run, no need to look up a log filename. Can't be combined with any other flag. |
| `-T`, `--type movies\|tv` | Manually set the library type instead of auto-detecting or prompting. |
| `--service [N\|PATH]` | Run rename + cleanup automatically in the background. See [Running as a service](#running-as-a-service) below. |
Flags can be combined, e.g. `-rc` runs rename and cleanup back to back on the same share, `-rf` forces a full rename scan, `-ty` previews everything without prompting. Flags can be combined, e.g. `-rc` runs rename and cleanup back to back on the same share, `-rf` forces a full rename scan, `-ty` previews everything without prompting.
--- ---
## Running as a service
Instead of running the script by hand every time you add something, `--service` runs it automatically in the background, on a timer, so newly added movies/shows get renamed within a minute or two of showing up.
```
python plex-library-tool.py --service # pick share(s) interactively, then start
python plex-library-tool.py --service "/Volumes/Movies" # add a share and start (default: every 60s)
python plex-library-tool.py --service 120 # set the interval to 120 seconds and start
python plex-library-tool.py --service start # start using the current configuration
python plex-library-tool.py --service stop # stop the service
```
Each cycle runs the equivalent of `-rcy` (rename + cleanup, no prompts) on every configured share. It runs as a detached background process, so it keeps running after you close the terminal. Progress is written to `logs/service.log` instead of the screen. Settings (shares + interval) are stored in `service.json` next to the script; you can add more shares later by running `--service <path>` again while it's running, no need to stop it first.
The service reuses your saved TMDb API key and language from `.env`, so run the script normally at least once first if you haven't already.
---
## Configuration files ## Configuration files
These live alongside the script and are all optional. The script works out of the box with sensible defaults. Every file is fully documented with comments and examples inside it. These live alongside the script and are all optional. The script works out of the box with sensible defaults. Every file is fully documented with comments and examples inside it.
+1
View File
@@ -147,6 +147,7 @@ files:
- "*.jpg" - "*.jpg"
- "*.jpeg" - "*.jpeg"
- "*.png" - "*.png"
- "*.smi"
- "*.ico" - "*.ico"
- "*.url" - "*.url"
- "*.sfv" - "*.sfv"
+403 -2
View File
@@ -12,8 +12,10 @@ import os
import platform import platform
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import time
import unicodedata import unicodedata
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@@ -29,6 +31,9 @@ TMDB_BASE = "https://api.themoviedb.org/3"
DELETE_FILE = SCRIPT_DIR / "delete.yaml" DELETE_FILE = SCRIPT_DIR / "delete.yaml"
NAMES_FILE = SCRIPT_DIR / "names.yaml" NAMES_FILE = SCRIPT_DIR / "names.yaml"
CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash" CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash"
SERVICE_CONFIG_FILE = SCRIPT_DIR / "service.json"
SERVICE_LOG_FILE = LOG_DIR / "service.log"
DEFAULT_SERVICE_INTERVAL = 60
VERBOSE = False VERBOSE = False
@@ -47,6 +52,34 @@ SUBTITLE_FOLDER_NAMES = {"subs", "subtitles"}
IGNORABLE_JUNK_FILENAMES = {".ds_store", "thumbs.db", "desktop.ini", ".localized"} IGNORABLE_JUNK_FILENAMES = {".ds_store", "thumbs.db", "desktop.ini", ".localized"}
FILE_STABILITY_AGE_THRESHOLD = 30
FILE_STABILITY_WAIT_SECONDS = 2
def is_file_stable(path):
try:
stat1 = path.stat()
except OSError:
return False
age = time.time() - stat1.st_mtime
if age > FILE_STABILITY_AGE_THRESHOLD:
return True
vprint(f" {path.name}: modified {age:.0f}s ago, checking if still being written")
size1 = stat1.st_size
time.sleep(FILE_STABILITY_WAIT_SECONDS)
try:
stat2 = path.stat()
except OSError:
return False
stable = stat2.st_size == size1
if not stable:
vprint(f" {path.name}: size changed ({size1} -> {stat2.st_size}), still being written")
return stable
def is_ignorable_junk_file(path): def is_ignorable_junk_file(path):
name = path.name.lower() name = path.name.lower()
@@ -1409,13 +1442,21 @@ def subtitle_descriptor_from_name(name):
def language_tag_from_name(name): def language_tag_from_name(name):
stem = name.rsplit(".", 1)[0] if "." in name else name stem = name.rsplit(".", 1)[0] if "." in name else name
segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s] segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s]
if len(segments) < 2: if not segments:
return None return None
trimmed = list(segments) trimmed = list(segments)
while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS: while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS:
trimmed.pop() trimmed.pop()
if len(segments) == 1:
only = trimmed[-1] if trimmed else None
if only in ENGLISH_LANGUAGE_TOKENS:
return "en"
if only in OTHER_LANGUAGE_TOKENS_RELIABLE:
return "foreign"
return None
tag_window = set(segments[-2:]) tag_window = set(segments[-2:])
last_only = {trimmed[-1]} if trimmed else set() last_only = {trimmed[-1]} if trimmed else set()
@@ -1432,13 +1473,21 @@ def language_tag_from_name(name):
def specific_language_from_name(name): def specific_language_from_name(name):
stem = name.rsplit(".", 1)[0] if "." in name else name stem = name.rsplit(".", 1)[0] if "." in name else name
segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s] segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s]
if len(segments) < 2: if not segments:
return None return None
trimmed = list(segments) trimmed = list(segments)
while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS: while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS:
trimmed.pop() trimmed.pop()
if len(segments) == 1:
only = trimmed[-1] if trimmed else None
if only in ENGLISH_LANGUAGE_TOKENS:
return "en"
if only in OTHER_LANGUAGE_TOKENS_RELIABLE:
return LANGUAGE_ALIAS_TO_CODE.get(only)
return None
tag_window = segments[-2:] tag_window = segments[-2:]
last_only = trimmed[-1] if trimmed else None last_only = trimmed[-1] if trimmed else None
@@ -1885,6 +1934,11 @@ def rename_season_folder_files(folder, final_name, log, api_key=None, tmdb_id=No
organize_subtitle_folder(sub, log) organize_subtitle_folder(sub, log)
for item in list_video_files(sub): for item in list_video_files(sub):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".") ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, season, api_key, tmdb_id) file_season, episode, marker = resolve_season_folder_file(item, season, api_key, tmdb_id)
target_season = file_season if file_season is not None else season target_season = file_season if file_season is not None else season
@@ -2066,6 +2120,11 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na
organize_subtitle_folder(sub, log) organize_subtitle_folder(sub, log)
for item in list_video_files(sub): for item in list_video_files(sub):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".") ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, season) file_season, episode, marker = resolve_season_folder_file(item, season)
item_season = file_season if file_season is not None else season item_season = file_season if file_season is not None else season
@@ -2109,6 +2168,11 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na
organize_subtitle_folder(source_folder, log) organize_subtitle_folder(source_folder, log)
for item in list_video_files(source_folder): for item in list_video_files(source_folder):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".") ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, folder_season) file_season, episode, marker = resolve_season_folder_file(item, folder_season)
item_season = file_season if file_season is not None else folder_season item_season = file_season if file_season is not None else folder_season
@@ -2267,6 +2331,11 @@ def rename_video_files(folder, media_type, final_name, log, api_key, tmdb_id=Non
organize_subtitle_folder(folder, log) organize_subtitle_folder(folder, log)
multi = len(files) > 1 multi = len(files) > 1
for item in files: for item in files:
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".") ext = item.suffix.lower().lstrip(".")
name = final_name name = final_name
if multi: if multi:
@@ -2298,6 +2367,11 @@ def rename_video_files(folder, media_type, final_name, log, api_key, tmdb_id=Non
return renamed, skipped return renamed, skipped
for item in files: for item in files:
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".") ext = item.suffix.lower().lstrip(".")
se = parse_season_episode(item.name) se = parse_season_episode(item.name)
subtitles = gather_video_subtitles(item, True) subtitles = gather_video_subtitles(item, True)
@@ -2856,6 +2930,13 @@ def process_loose_movie_files(share, api_key, log, test_mode, test_limit, args):
if test_mode and test_limit > 0 and shown >= test_limit: if test_mode and test_limit > 0 and shown >= test_limit:
break break
if not is_file_stable(item):
print(f"[{index}/{total}] {item.name}")
print(f"Skipping (still being copied): {item.name}")
shown += 1
folders_skipped += 1
continue
print(f"[{index}/{total}] {item.name}") print(f"[{index}/{total}] {item.name}")
final_name, match_year, _, error = lookup_folder(api_key, "movie", item.stem) final_name, match_year, _, error = lookup_folder(api_key, "movie", item.stem)
if error: if error:
@@ -2924,6 +3005,13 @@ def process_loose_tv_files(share, api_key, log, test_mode, test_limit, args):
if test_mode and test_limit > 0 and shown >= test_limit: if test_mode and test_limit > 0 and shown >= test_limit:
break break
if not is_file_stable(item):
print(f"[{index}/{total}] {item.name}")
print(f"Skipping (still being copied): {item.name}")
shown += 1
files_skipped += 1
continue
print(f"[{index}/{total}] {item.name}") print(f"[{index}/{total}] {item.name}")
final_name, match_year, match_id, error = lookup_folder(api_key, "tv", item.stem) final_name, match_year, match_id, error = lookup_folder(api_key, "tv", item.stem)
if error: if error:
@@ -3651,6 +3739,19 @@ def build_parser():
help="Manually specify the library type (movies or tv) instead of auto-detecting from the share name " help="Manually specify the library type (movies or tv) instead of auto-detecting from the share name "
"or prompting." "or prompting."
) )
parser.add_argument(
"--service",
nargs="?", const="", default=None, metavar="start|stop|SECONDS|PATH",
help="Run rename+cleanup automatically in the background on the configured share(s). "
"--service start starts it, --service stop stops it. --service N sets the interval "
"in seconds and starts it. --service PATH adds a share to the auto-scan list and "
"starts the service. --service with no value walks through selecting shares interactively."
)
parser.add_argument(
"--service-worker",
action="store_true",
help=argparse.SUPPRESS,
)
return parser return parser
@@ -3681,6 +3782,298 @@ def parse_args():
return build_parser().parse_args(expand_bundled_flags(sys.argv[1:])) return build_parser().parse_args(expand_bundled_flags(sys.argv[1:]))
def get_api_key_noninteractive():
env_key = os.environ.get("TMDB_API_KEY")
if env_key:
return env_key.strip()
key = parse_env_file(API_KEY_FILE).get("TMDB_API_KEY", "").strip()
return key or None
def load_service_config():
if SERVICE_CONFIG_FILE.exists():
try:
data = json.loads(SERVICE_CONFIG_FILE.read_text())
if isinstance(data, dict):
data.setdefault("paths", [])
data.setdefault("interval", DEFAULT_SERVICE_INTERVAL)
data.setdefault("pid", None)
return data
except (json.JSONDecodeError, OSError):
pass
return {"paths": [], "interval": DEFAULT_SERVICE_INTERVAL, "pid": None}
def save_service_config(config):
SERVICE_CONFIG_FILE.write_text(json.dumps(config, indent=2))
def is_process_running(pid):
if not pid:
return False
if platform.system() == "Windows":
try:
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True, text=True, timeout=5
)
return str(pid) in result.stdout
except (subprocess.SubprocessError, OSError):
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def terminate_process(pid):
if platform.system() == "Windows":
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5)
return True
except (subprocess.SubprocessError, OSError):
return False
try:
os.kill(pid, signal.SIGTERM)
return True
except OSError:
return False
def stop_service():
config = load_service_config()
pid = config.get("pid")
if not pid or not is_process_running(pid):
print("Service is not running.")
config["pid"] = None
save_service_config(config)
return
ok = terminate_process(pid)
config["pid"] = None
save_service_config(config)
if ok:
print(f"Service stopped (PID {pid}).")
else:
print(f"Could not stop the service (PID {pid}). You may need to terminate it manually.")
def start_service_daemon(config):
existing_pid = config.get("pid")
if existing_pid and is_process_running(existing_pid):
print(f"Service already running (PID {existing_pid}), interval {config.get('interval', DEFAULT_SERVICE_INTERVAL)}s.")
print("Stop it first with --service stop if you want to change settings and restart.")
return
get_api_key()
get_tmdb_language()
LOG_DIR.mkdir(exist_ok=True)
log_fh = open(SERVICE_LOG_FILE, "a")
script_path = Path(__file__).resolve()
cmd = [sys.executable, str(script_path), "--service-worker"]
popen_kwargs = {"stdout": log_fh, "stderr": log_fh, "stdin": subprocess.DEVNULL}
if platform.system() == "Windows":
popen_kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
log_fh.close()
config["pid"] = proc.pid
save_service_config(config)
interval = config.get("interval", DEFAULT_SERVICE_INTERVAL)
print(f"Service started (PID {proc.pid}), interval {interval}s.")
if config.get("paths"):
print("Scanning:")
for p in config["paths"]:
print(f" {p}")
else:
print("No shares configured yet. Add one with --service <path>.")
print(f"Log: {SERVICE_LOG_FILE}")
print("Stop anytime with: --service stop")
def interactive_configure_service():
config = load_service_config()
paths = list(config.get("paths", []))
while True:
mounts = [m for m in find_smb_mounts() if m not in paths]
if mounts:
print("Available shares:")
for i, m in enumerate(mounts, 1):
print(f" {i}) {m}")
sel = input(f"Select a share to add [1-{len(mounts)}], type a path, or press Enter to stop adding: ").strip()
else:
sel = input("No additional SMB shares found. Type a path to add, or press Enter to stop adding: ").strip()
if not sel:
break
if sel.isdigit() and 1 <= int(sel) <= len(mounts):
chosen = mounts[int(sel) - 1]
else:
chosen = sel
if not Path(chosen).is_dir():
print(f"Path not found: {chosen}")
continue
if chosen not in paths:
paths.append(chosen)
print(f"Added: {chosen}")
else:
print(f"Already added: {chosen}")
again = input("Add another share? [y/N]: ").strip().lower()
if again != "y":
break
config["paths"] = paths
config.setdefault("interval", DEFAULT_SERVICE_INTERVAL)
save_service_config(config)
if not paths:
print("No shares configured. Service not started.")
return
interval = config["interval"]
resp = input(f"Start the service now with a {interval}s interval? [Y/n]: ").strip().lower()
if resp in ("", "y", "yes"):
start_service_daemon(config)
else:
print("Not started. Run --service start to start it later.")
def handle_service_command(value):
value = value.strip()
if value == "":
interactive_configure_service()
return
if value.lower() == "stop":
stop_service()
return
if value.lower() == "start":
config = load_service_config()
if not config.get("paths"):
print("No shares configured yet.")
interactive_configure_service()
return
start_service_daemon(config)
return
if re.fullmatch(r'-?\d+', value):
interval = int(value)
if interval <= 0:
print("Interval must be a positive number of seconds. Use --service stop to stop it.")
sys.exit(1)
config = load_service_config()
config["interval"] = interval
save_service_config(config)
if not config.get("paths"):
print(f"Interval set to {interval}s. No shares configured yet.")
interactive_configure_service()
return
start_service_daemon(config)
return
path = Path(value)
if not path.is_dir():
print(f"Path not found: {value}")
sys.exit(1)
config = load_service_config()
paths = config.get("paths", [])
resolved = str(path.resolve())
if resolved not in paths:
paths.append(resolved)
config["paths"] = paths
print(f"Added to service scan list: {resolved}")
else:
print(f"Already in service scan list: {resolved}")
save_service_config(config)
start_service_daemon(config)
def run_service_pass(path_str, media_type):
class ServiceArgs:
pass
args = ServiceArgs()
args.rename = path_str
args.cleanup = path_str
args.type = media_type
args.yes = True
args.force = False
args.verbose = False
args.test = None
log = RenameLog()
run_scan(args, log)
print()
run_cleanup(args, log)
def run_service_worker():
global VERBOSE
VERBOSE = False
def ts():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts()}] Service worker started (PID {os.getpid()}).")
sys.stdout.flush()
while True:
config = load_service_config()
paths = config.get("paths", [])
interval = config.get("interval", DEFAULT_SERVICE_INTERVAL)
if not paths:
print(f"[{ts()}] No shares configured. Waiting {interval}s.")
for path_str in paths:
path = Path(path_str)
if not path.is_dir():
print(f"[{ts()}] Skipping missing path: {path_str}")
continue
media_type = infer_media_type(path)
if media_type is None:
print(f"[{ts()}] Skipping (cannot determine movies/tv from path name): {path_str}")
continue
if not get_api_key_noninteractive():
print(f"[{ts()}] No TMDb API key configured, skipping this cycle.")
break
print(f"[{ts()}] Scanning: {path_str} ({media_type})")
try:
run_service_pass(path_str, media_type)
except SystemExit:
pass
except Exception as e:
print(f"[{ts()}] Error scanning {path_str}: {e}")
sys.stdout.flush()
sys.stdout.flush()
time.sleep(interval)
def main(): def main():
if len(sys.argv) == 1: if len(sys.argv) == 1:
build_parser().print_help() build_parser().print_help()
@@ -3688,6 +4081,14 @@ def main():
args = parse_args() args = parse_args()
if args.service_worker:
run_service_worker()
return
if args.service is not None:
handle_service_command(args.service)
return
if args.undo: if args.undo:
other_args_used = ( other_args_used = (
args.yes or args.force or args.verbose or args.test is not None args.yes or args.force or args.verbose or args.test is not None