Skip to content

Commit cc9ed04

Browse files
Copilotkfstorm
andauthored
Support multiple root directories for monitoring (#80)
Sonarr users often have multiple library root folders, and using a common parent directory risks processing Radarr (or other) folders unintentionally. This adds support for configuring multiple root directories to monitor independently. ## Config changes `REWRITE_ROOT_DIR` (single path) remains fully backward compatible. New `REWRITE_ROOT_DIRS` takes priority and accepts a comma-separated list: ```bash REWRITE_ROOT_DIRS=/tv,/anime ``` The `rewrite_root_dir: Path` field is replaced by `rewrite_root_dirs: list[Path]`. ## Component updates - **`file_scanner`** — iterates over all root dirs per scan cycle; checks `stop_event` before each root dir and `break`s the inner file loop on signal - **`file_monitor`** — registers a separate watchdog watch per root dir - **`file_utils`** — adds `find_root_dir_for_file(file_path, root_dirs)` to resolve which root dir owns a given file path - **`backup_utils`** — backups now use the file's full absolute path (minus the leading `/`) as the storage key, e.g. `/tv/Show A/tvshow.nfo` → `<BACKUP_DIR>/tv/Show A/tvshow.nfo`. This guarantees no collision when files from different root dirs share the same relative path. The `root_dir` parameter has been removed from all backup functions. An optional `root_dirs` parameter provides backward-compatible fallback: when the new-format path is not found, functions automatically search the legacy root-dir-relative paths so existing backups from older versions are still found and restored without any migration step. New backups are always written in the new format. - **`metadata_processor` / `image_processor`** — pass `rewrite_root_dirs` to backup calls to enable the legacy fallback - **`rollback_service`** — reconstructs the original absolute path directly from the backup path (`Path("/") / backup_file.relative_to(backup_dir)`); falls back to the legacy root-dir-relative search for backups created by older versions - **`main` / `rewrite_service`** — log each monitored directory at startup ## Documentation Updated `README.md` with: - `REWRITE_ROOT_DIRS` configuration option and backward-compat note for `REWRITE_ROOT_DIR` - Multi-directory Docker run and Docker Compose examples - Backup directory structure explanation, including backward-compatibility note for old-format backups - Updated startup log, volume mapping, and rollback examples --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kfstorm <3626358+kfstorm@users.noreply.github.com>
1 parent fb94675 commit cc9ed04

22 files changed

Lines changed: 1154 additions & 439 deletions

README.md

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ docker run -d \
5050
--name sonarr-metadata-rewrite \
5151
--user $(id -u):$(id -g) \
5252
-e TMDB_API_KEY=your_api_key_here \
53-
-e REWRITE_ROOT_DIR=/media \
53+
-e REWRITE_ROOT_DIRS=/tv,/anime \
5454
-e PREFERRED_LANGUAGES=zh-CN,ja-JP \
55-
-v /path/to/your/tv/shows:/media \
55+
-v /path/to/tv/shows:/tv \
56+
-v /path/to/anime:/anime \
5657
-v sonarr-metadata-cache:/app/cache \
5758
-v sonarr-metadata-backups:/app/backups \
5859
--restart unless-stopped \
@@ -70,12 +71,13 @@ services:
7071
user: "${UID:-1000}:${GID:-1000}"
7172
environment:
7273
- TMDB_API_KEY=your_api_key_here
73-
- REWRITE_ROOT_DIR=/media
74+
- REWRITE_ROOT_DIRS=/tv,/anime
7475
- PREFERRED_LANGUAGES=zh-CN,ja-JP
7576
# Optional: customize cache duration (default: 30 days)
7677
# - CACHE_DURATION_HOURS=168
7778
volumes:
78-
- /path/to/your/tv/shows:/media
79+
- /path/to/tv/shows:/tv
80+
- /path/to/anime:/anime
7981
- sonarr-metadata-cache:/app/cache
8082
- sonarr-metadata-backups:/app/backups
8183
restart: unless-stopped
@@ -91,11 +93,15 @@ volumes:
9193
9294
```bash
9395
TMDB_API_KEY=your_api_read_access_token_here # Your TMDB API Read Access Token
94-
REWRITE_ROOT_DIR=/media # Path to your TV shows (inside container)
96+
# One or more media directories (comma-separated) to watch for Sonarr .nfo files
97+
REWRITE_ROOT_DIRS=/tv,/anime
9598
# Comma-separated language codes in priority order
9699
PREFERRED_LANGUAGES=zh-CN,ja-JP
97100
```
98101

102+
`REWRITE_ROOT_DIR` (singular, one path) is also accepted for backward
103+
compatibility. `REWRITE_ROOT_DIRS` takes priority when both are set.
104+
99105
### Optional Settings (with defaults)
100106

101107
```bash
@@ -121,6 +127,13 @@ TMDB_MAX_RETRY_DELAY=60.0 # Max retry delay (default: 60.0)
121127
ORIGINAL_FILES_BACKUP_DIR=./backups # Backup original files (default: ./backups)
122128
# Applies to both .nfo and images. Set to
123129
# empty string to disable backups.
130+
# Files are stored under their full absolute
131+
# path (e.g. /app/backups/tv/Show/tvshow.nfo)
132+
# so backups from different root dirs never
133+
# collide.
134+
# Backups created by older single-root-dir
135+
# versions are still found and restored
136+
# automatically (backward compatible).
124137

125138
# Service Mode
126139
SERVICE_MODE=rewrite # Service mode: 'rewrite' or 'rollback'
@@ -198,12 +211,13 @@ id -g # Shows your group ID (GID)
198211

199212
### Highly Recommended Volume Mappings
200213

201-
While only the media directory is strictly required, these volumes will
214+
While only the media directories are strictly required, these volumes will
202215
improve your experience:
203216

204217
```bash
205-
# Media directory (required)
206-
-v /path/to/your/tv/shows:/media
218+
# Media directories (required - mount each root dir separately)
219+
-v /path/to/your/tv/shows:/tv
220+
-v /path/to/your/anime:/anime
207221

208222
# Cache directory (highly recommended - persists translation cache across
209223
# container restarts)
@@ -229,7 +243,8 @@ You'll see something like:
229243
```text
230244
🚀 Starting Sonarr Metadata Rewrite...
231245
✅ TMDB API key loaded (ending in ...xyz)
232-
📁 Monitoring directory: /media
246+
📁 Monitoring directory: /tv
247+
📁 Monitoring directory: /anime
233248
🌍 Preferred languages: ['zh-CN', 'ja-JP']
234249
✅ Service started successfully
235250
```
@@ -282,9 +297,10 @@ docker run --rm \
282297
--user $(id -u):$(id -g) \
283298
-e SERVICE_MODE=rollback \
284299
-e TMDB_API_KEY=your_api_key_here \
285-
-e REWRITE_ROOT_DIR=/media \
300+
-e REWRITE_ROOT_DIRS=/tv,/anime \
286301
-e PREFERRED_LANGUAGES=zh-CN,ja-JP \
287-
-v /path/to/your/tv/shows:/media \
302+
-v /path/to/tv/shows:/tv \
303+
-v /path/to/anime:/anime \
288304
-v sonarr-metadata-backups:/app/backups \
289305
kfstorm/sonarr-metadata-rewrite:latest
290306
```

src/sonarr_metadata_rewrite/backup_utils.py

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,71 @@
22
33
Provides functions to create backups, retrieve backup paths, and restore files
44
from backups, with support for stem-matching across different file extensions.
5+
6+
Backup storage structure
7+
------------------------
8+
Each file is stored under the backup directory using its full absolute path
9+
(with the leading ``/`` stripped). For example, the file
10+
``/media/sonarr/Show A/tvshow.nfo`` is backed up as
11+
``<BACKUP_DIR>/media/sonarr/Show A/tvshow.nfo``. Using the full absolute path
12+
as the key guarantees that files from different root directories never collide,
13+
even when those directories contain identically-named entries.
14+
15+
Legacy format (backward compatibility)
16+
---------------------------------------
17+
Versions prior to multi-root-dir support stored backups relative to the single
18+
configured root directory, e.g. ``<BACKUP_DIR>/Show A/tvshow.nfo`` (with root
19+
``/media/sonarr``). The functions below accept an optional ``root_dirs``
20+
parameter: when the new-format path is not found in the backup directory, they
21+
fall back to the legacy relative path under each provided root directory. This
22+
ensures that users who upgrade retain access to their existing backups.
23+
24+
New backups are always written in the new format; the legacy path is only ever
25+
*read*, never written.
526
"""
627

728
import shutil
829
from pathlib import Path
930

1031

32+
def _legacy_backup_path(
33+
file_path: Path, backup_dir: Path, root_dir: Path
34+
) -> Path | None:
35+
"""Return the legacy (root-dir-relative) backup path, or None if not applicable.
36+
37+
This only checks whether ``file_path`` is under ``root_dir``; it does not
38+
verify whether the resulting path exists.
39+
40+
Args:
41+
file_path: Absolute path to the file
42+
backup_dir: Backup directory root
43+
root_dir: Root directory to compute the legacy relative path from
44+
45+
Returns:
46+
The legacy backup path, or None if file_path is not under root_dir
47+
"""
48+
try:
49+
relative = file_path.relative_to(root_dir)
50+
except ValueError:
51+
return None
52+
return backup_dir / relative
53+
54+
1155
def get_backup_path(
12-
file_path: Path, backup_dir: Path | None, root_dir: Path
56+
file_path: Path,
57+
backup_dir: Path | None,
58+
root_dirs: list[Path] | None = None,
1359
) -> Path | None:
1460
"""Get backup file path for a given file.
1561
62+
Checks the new absolute-path format first, then falls back to the legacy
63+
root-dir-relative format when ``root_dirs`` is supplied.
64+
1665
Args:
17-
file_path: Path to the file to get backup path for
66+
file_path: Absolute path to the file to get backup path for
1867
backup_dir: Backup directory root (None to skip)
19-
root_dir: Root directory for calculating relative path
68+
root_dirs: Optional list of root directories used as a legacy-format
69+
fallback for backups created by earlier versions of this tool.
2070
2171
Returns:
2272
Path to backup file if backup directory is configured and backup exists,
@@ -26,33 +76,51 @@ def get_backup_path(
2676
if backup_dir is None:
2777
return None
2878

29-
# Calculate backup path using same logic as create_backup()
30-
relative_path = file_path.relative_to(root_dir)
31-
backup_path = backup_dir / relative_path
79+
# --- New format: full absolute path under backup_dir ---
80+
backup_path = backup_dir / file_path.relative_to("/")
3281

33-
# Check for exact path match first
3482
if backup_path.exists():
3583
return backup_path
3684

37-
# For files that might have different extensions (e.g., images),
38-
# check if any file with the same stem already exists in backup dir
3985
if backup_path.parent.exists():
4086
stem = backup_path.stem
4187
for existing_file in backup_path.parent.iterdir():
4288
if existing_file.is_file() and existing_file.stem == stem:
43-
# Found backup with same filename stem
4489
return existing_file
4590

91+
# --- Legacy format fallback (backward compat) ---
92+
if root_dirs:
93+
for root_dir in root_dirs:
94+
legacy = _legacy_backup_path(file_path, backup_dir, root_dir)
95+
if legacy is None:
96+
continue
97+
if legacy.exists():
98+
return legacy
99+
if legacy.parent.exists():
100+
stem = legacy.stem
101+
for existing_file in legacy.parent.iterdir():
102+
if existing_file.is_file() and existing_file.stem == stem:
103+
return existing_file
104+
46105
return None
47106

48107

49-
def create_backup(file_path: Path, backup_dir: Path | None, root_dir: Path) -> bool:
50-
"""Create backup of a file maintaining directory structure.
108+
def create_backup(
109+
file_path: Path,
110+
backup_dir: Path | None,
111+
root_dirs: list[Path] | None = None,
112+
) -> bool:
113+
"""Create backup of a file maintaining its full absolute path structure.
114+
115+
Checks the new absolute-path format first and the legacy root-dir-relative
116+
format second before creating a new backup, so that existing backups are
117+
never overwritten regardless of which format they use.
51118
52119
Args:
53-
file_path: Path to file to backup
120+
file_path: Absolute path to file to backup
54121
backup_dir: Backup directory root (None to skip backup)
55-
root_dir: Root directory for calculating relative path
122+
root_dirs: Optional list of root directories used as a legacy-format
123+
fallback for backups created by earlier versions of this tool.
56124
57125
Returns:
58126
True if backup was created or already exists, False if backup disabled
@@ -63,53 +131,63 @@ def create_backup(file_path: Path, backup_dir: Path | None, root_dir: Path) -> b
63131
if not file_path.exists():
64132
return False
65133

66-
# Calculate backup path maintaining directory structure
67-
relative_path = file_path.relative_to(root_dir)
68-
backup_path = backup_dir / relative_path
134+
# --- New format path ---
135+
backup_path = backup_dir / file_path.relative_to("/")
69136

70-
# Don't overwrite existing backup with exact same path
71137
if backup_path.exists():
72138
return True
73139

74-
# For files that might have different extensions (e.g., images),
75-
# check if any file with the same stem already exists in backup dir
76140
if backup_path.parent.exists():
77141
stem = backup_path.stem
78142
for existing_file in backup_path.parent.iterdir():
79143
if existing_file.is_file() and existing_file.stem == stem:
80-
# Backup with same filename stem already exists
81144
return True
82145

83-
# Ensure backup directory exists
146+
# --- Legacy format check (backward compat): don't create a new backup if
147+
# an old-format backup already covers this file. ---
148+
if root_dirs:
149+
for root_dir in root_dirs:
150+
legacy = _legacy_backup_path(file_path, backup_dir, root_dir)
151+
if legacy is None:
152+
continue
153+
if legacy.exists():
154+
return True # Legacy backup present; preserve it, don't overwrite
155+
if legacy.parent.exists():
156+
stem = legacy.stem
157+
for existing_file in legacy.parent.iterdir():
158+
if existing_file.is_file() and existing_file.stem == stem:
159+
return True # Legacy stem-match found
160+
161+
# --- Create new backup at new-format path ---
84162
backup_path.parent.mkdir(parents=True, exist_ok=True)
85-
86-
# Copy file to backup location
87163
shutil.copy2(file_path, backup_path)
88164
return True
89165

90166

91167
def restore_from_backup(
92-
file_path: Path, backup_dir: Path | None, root_dir: Path
168+
file_path: Path,
169+
backup_dir: Path | None,
170+
root_dirs: list[Path] | None = None,
93171
) -> bool:
94172
"""Restore file from backup, handling stem-matching and extension changes.
95173
96174
This function handles the case where a backup might have a different extension
97175
than the current file (e.g., poster.png backup for poster.jpg current).
98176
It will:
99-
1. Find the backup using stem-matching
177+
1. Find the backup using stem-matching (new format first, then legacy)
100178
2. Delete any files with the same stem but different extensions
101179
3. Copy the backup to the original location
102180
103181
Args:
104-
file_path: Path to file to restore
182+
file_path: Absolute path to file to restore
105183
backup_dir: Backup directory root (None to skip)
106-
root_dir: Root directory for calculating relative path
184+
root_dirs: Optional list of root directories for legacy-format fallback.
107185
108186
Returns:
109187
True if file was restored from backup, False otherwise
110188
"""
111-
# Find backup path
112-
backup_path = get_backup_path(file_path, backup_dir, root_dir)
189+
# Find backup path (new format first, then legacy)
190+
backup_path = get_backup_path(file_path, backup_dir, root_dirs)
113191
if not backup_path:
114192
return False
115193

src/sonarr_metadata_rewrite/config.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ class CustomEnvSettings(PydanticBaseSettingsSource):
1616
"""Custom environment settings source that handles comma-separated lists."""
1717

1818
def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
19-
"""Get field value from environment, handling preferred_languages specially."""
19+
"""Get field value from environment, handling special fields."""
2020
env_name = field_name.upper()
2121
env_val = os.getenv(env_name)
2222

23+
if field_name == "rewrite_root_dirs":
24+
# Support REWRITE_ROOT_DIRS (comma-separated, new) and
25+
# REWRITE_ROOT_DIR (single path, backward compatible)
26+
env_val = os.getenv("REWRITE_ROOT_DIRS") or os.getenv("REWRITE_ROOT_DIR")
27+
if env_val is None:
28+
return None, "REWRITE_ROOT_DIRS", False
29+
return env_val, "REWRITE_ROOT_DIRS", False
30+
2331
if env_val is None:
2432
return None, env_name, False
2533

@@ -33,8 +41,8 @@ def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
3341
def prepare_field_value(
3442
self, field_name: str, field: Any, value: Any, value_is_complex: bool
3543
) -> Any:
36-
"""Prepare field value, skipping JSON parsing for preferred_languages."""
37-
if field_name == "preferred_languages":
44+
"""Prepare field value, skipping JSON parsing for list fields."""
45+
if field_name in {"preferred_languages", "rewrite_root_dirs"}:
3846
return value
3947
return super().prepare_field_value(field_name, field, value, value_is_complex)
4048

@@ -88,8 +96,11 @@ def settings_customise_sources(
8896
tmdb_api_key: str = Field(description="TMDB API key for translation requests")
8997

9098
# Directory monitoring
91-
rewrite_root_dir: Path = Field(
92-
description="Root directory to monitor and rewrite .nfo files"
99+
rewrite_root_dirs: list[Path] = Field(
100+
description=(
101+
"Root directories to monitor and rewrite .nfo files "
102+
"(comma-separated list)"
103+
)
93104
)
94105
periodic_scan_interval_seconds: int = Field(
95106
default=86400, description="How often to scan the directory (seconds)"
@@ -100,6 +111,19 @@ def settings_customise_sources(
100111
description="Preferred languages in priority order (comma-separated)"
101112
)
102113

114+
@field_validator("rewrite_root_dirs", mode="before")
115+
@classmethod
116+
def parse_rewrite_root_dirs(cls, v: str | list[Any]) -> list[Path]:
117+
"""Parse root dirs from comma-separated string or list."""
118+
if isinstance(v, list):
119+
return [Path(p) for p in v]
120+
if isinstance(v, str):
121+
paths = [Path(p.strip()) for p in v.split(",") if p.strip()]
122+
if not paths:
123+
raise ValueError("rewrite_root_dirs cannot be empty")
124+
return paths
125+
raise ValueError("rewrite_root_dirs must be a comma-separated string or list")
126+
103127
@field_validator("preferred_languages", mode="before")
104128
@classmethod
105129
def parse_preferred_languages(cls, v: str | list[str]) -> list[str]:

0 commit comments

Comments
 (0)