jellyplex-sync 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/PKG-INFO +32 -6
  2. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/README.md +31 -5
  3. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/pyproject.toml +1 -1
  4. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/pyproject.toml.orig +1 -1
  5. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/compare.py +29 -3
  6. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/discover.py +52 -5
  7. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/json_output.py +14 -0
  8. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/library.py +23 -0
  9. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/plan.py +28 -4
  10. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/planner.py +99 -10
  11. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/realize.py +48 -1
  12. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/report.py +44 -1
  13. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/sync.py +115 -3
  14. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/LICENSE +0 -0
  15. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/__init__.py +0 -0
  16. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/__init__.py +0 -0
  17. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/main.py +0 -0
  18. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/sync.py +0 -0
  19. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/disambig.py +0 -0
  20. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/formats.py +0 -0
  21. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/jellyfin.py +0 -0
  22. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/materializer.py +0 -0
  23. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/model.py +0 -0
  24. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/plex.py +0 -0
  25. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/py.typed +0 -0
  26. {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jellyplex-sync
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
5
5
  Keywords: plex,jellyfin,media-library,movies,sync,hardlink,mirror
6
6
  Author: Stefan Schönberger
@@ -32,7 +32,7 @@ Can't decide between Jellyfin and Plex? This tool might help. It synchronizes yo
32
32
 
33
33
  The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., `extras`, `subtitles`) are also mirrored. Loose top-level files (posters, NFOs, subtitles) are synced 1:1 by default since 0.2.0. With `--delete`, any files or folders in the target that are no longer present in the source will be removed.
34
34
 
35
- > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning.
35
+ > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning. Folders you mark with `.jellyplex-sync-ignore` are the exception, see [Excluding folders](#excluding-folders).
36
36
 
37
37
  > **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
38
38
 
@@ -153,7 +153,8 @@ Exit codes follow the Unix `diff` convention:
153
153
 
154
154
  - `0` — in sync.
155
155
  - `1` — differences found.
156
- - `2` — setup error (missing directories, indecipherable format).
156
+ - `2` — setup error (missing directories, indecipherable format), or one of the
157
+ library folders contains `.jellyplex-sync-ignore`.
157
158
 
158
159
  #### Example
159
160
 
@@ -236,6 +237,28 @@ Preview first, keeping the source:
236
237
  jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
237
238
  ```
238
239
 
240
+ ### Excluding folders
241
+
242
+ To exclude a folder and everything below it, put an empty file named `.jellyplex-sync-ignore` into it:
243
+
244
+ ```bash
245
+ touch ~/"Media/Plex/Some Movie (2001) {imdb-tt0000000}/.jellyplex-sync-ignore"
246
+ ```
247
+
248
+ The marker means the same in both directions, so it does not matter whether the library is the source or the target of a run:
249
+
250
+ - **In the source**, the folder is treated as if it did not exist. Nothing in it is synced or imported.
251
+ - **In the target**, nothing is written into the folder and nothing is removed from it, not even with `--delete`. A marked folder that has no counterpart in the source is not reported as a stray.
252
+
253
+ The marker works at any level: in a movie folder, in an asset folder such as `other`, or further down. The marker file itself is never copied to the other library.
254
+
255
+ Things to know:
256
+
257
+ - **Marking a source folder does not keep its copy in the target.** If the folder was synced before, its copy in the target becomes a stray: it is reported, and `--delete` removes it. The source folder is not touched. To keep the copy, put a marker into the target folder as well.
258
+ - **A marked folder is also kept when `--delete` would remove a folder above it.** If a folder in the target has no counterpart in the source but contains a marked folder at any depth, the whole folder is kept.
259
+ - **A marker in the library folder itself stops the run.** `sync` and `import` change nothing, print a warning and exit with code 0. `diff` exits with code 2, and `plan` shows an empty plan. A marked source library is not treated as an empty library, so `--delete` cannot clear the target this way.
260
+ - At the end of a `sync` or `import` run, the number of marked folders is printed; `--verbose` lists them. `diff` and `plan` always list them. In the `--json` output they are in `marked_in_source` and `marked_in_target`.
261
+
239
262
  ### JSON output
240
263
 
241
264
  All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
@@ -247,9 +270,10 @@ The schema (still evolving — pin a version when consuming):
247
270
  - `source` / `target`: `{path, format}` for each endpoint.
248
271
  - `dry_run` (sync only): whether the run was a preview.
249
272
  - `summary`: counters; the set of fields depends on the operation.
250
- - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
251
- - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
273
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`, `marked_in_source`, `marked_in_target`.
274
+ - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`, `marked_in_source`, `marked_in_target`.
252
275
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
276
+ - `marked_in_source` / `marked_in_target`: full paths of the folders skipped because they contain `.jellyplex-sync-ignore` (see [Excluding folders](#excluding-folders)). A library folder in one of these lists means the run was stopped.
253
277
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
254
278
  - `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
255
279
  - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
@@ -321,8 +345,10 @@ This helper can also be used on other NAS systems or Linux servers — schedule
321
345
  - **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
322
346
  - **Asset folders** — Subdirectories (e.g., `other`, `interviews`) are processed recursively with the same materialization strategy. Note: rename your Jellyfin `extras` folder to `other`, since Plex does not recognize `extras`.
323
347
  - **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
324
- - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
348
+ - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed, except folders marked with `.jellyplex-sync-ignore` and folders that contain one. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
325
349
  - **Ignored items** — Entries the scanner couldn't classify (stray files at the library root, folders whose names don't parse) are reported in the summary and the `--json` `ignored` array. They are *not* carried over to the target — useful to verify before deleting the source.
350
+ - **Hidden files and folders are skipped.** Names starting with a dot (`.DS_Store`, `.stversions`, `.Trash`) are skipped at every level of the source library, by `import` as well. They do not appear in the list of ignored items.
351
+ - **Marked folders are skipped** on both sides, see [Excluding folders](#excluding-folders).
326
352
  - **Video-level clashes auto-resolve** — When two source files would translate to the same target name (a common P→J outcome: `[1080p].mkv` and `[1080p] [remux].mkv` both collapse to `- BD.mkv`), a short hash of the source filename gets appended (e.g. `Movie - BD [a3f7c819].mkv`). Both files get synced; spot the hash in the `plan` output or in `--json` via the per-file `disambiguation` field. Pre-0.3 the whole movie was skipped on clash.
327
353
  - **Folder-level clashes still abort** — Two source folders mapping to the same target folder name (e.g. via dropped `[bracket]` labels) is a structural error in the source library, not something to silently merge. The run is stopped with exit code 2, the clashing folders are listed, and you're asked to rename one side.
328
354
 
@@ -6,7 +6,7 @@ Can't decide between Jellyfin and Plex? This tool might help. It synchronizes yo
6
6
 
7
7
  The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., `extras`, `subtitles`) are also mirrored. Loose top-level files (posters, NFOs, subtitles) are synced 1:1 by default since 0.2.0. With `--delete`, any files or folders in the target that are no longer present in the source will be removed.
8
8
 
9
- > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning.
9
+ > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning. Folders you mark with `.jellyplex-sync-ignore` are the exception, see [Excluding folders](#excluding-folders).
10
10
 
11
11
  > **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
12
12
 
@@ -127,7 +127,8 @@ Exit codes follow the Unix `diff` convention:
127
127
 
128
128
  - `0` — in sync.
129
129
  - `1` — differences found.
130
- - `2` — setup error (missing directories, indecipherable format).
130
+ - `2` — setup error (missing directories, indecipherable format), or one of the
131
+ library folders contains `.jellyplex-sync-ignore`.
131
132
 
132
133
  #### Example
133
134
 
@@ -210,6 +211,28 @@ Preview first, keeping the source:
210
211
  jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
211
212
  ```
212
213
 
214
+ ### Excluding folders
215
+
216
+ To exclude a folder and everything below it, put an empty file named `.jellyplex-sync-ignore` into it:
217
+
218
+ ```bash
219
+ touch ~/"Media/Plex/Some Movie (2001) {imdb-tt0000000}/.jellyplex-sync-ignore"
220
+ ```
221
+
222
+ The marker means the same in both directions, so it does not matter whether the library is the source or the target of a run:
223
+
224
+ - **In the source**, the folder is treated as if it did not exist. Nothing in it is synced or imported.
225
+ - **In the target**, nothing is written into the folder and nothing is removed from it, not even with `--delete`. A marked folder that has no counterpart in the source is not reported as a stray.
226
+
227
+ The marker works at any level: in a movie folder, in an asset folder such as `other`, or further down. The marker file itself is never copied to the other library.
228
+
229
+ Things to know:
230
+
231
+ - **Marking a source folder does not keep its copy in the target.** If the folder was synced before, its copy in the target becomes a stray: it is reported, and `--delete` removes it. The source folder is not touched. To keep the copy, put a marker into the target folder as well.
232
+ - **A marked folder is also kept when `--delete` would remove a folder above it.** If a folder in the target has no counterpart in the source but contains a marked folder at any depth, the whole folder is kept.
233
+ - **A marker in the library folder itself stops the run.** `sync` and `import` change nothing, print a warning and exit with code 0. `diff` exits with code 2, and `plan` shows an empty plan. A marked source library is not treated as an empty library, so `--delete` cannot clear the target this way.
234
+ - At the end of a `sync` or `import` run, the number of marked folders is printed; `--verbose` lists them. `diff` and `plan` always list them. In the `--json` output they are in `marked_in_source` and `marked_in_target`.
235
+
213
236
  ### JSON output
214
237
 
215
238
  All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
@@ -221,9 +244,10 @@ The schema (still evolving — pin a version when consuming):
221
244
  - `source` / `target`: `{path, format}` for each endpoint.
222
245
  - `dry_run` (sync only): whether the run was a preview.
223
246
  - `summary`: counters; the set of fields depends on the operation.
224
- - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
225
- - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
247
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`, `marked_in_source`, `marked_in_target`.
248
+ - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`, `marked_in_source`, `marked_in_target`.
226
249
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
250
+ - `marked_in_source` / `marked_in_target`: full paths of the folders skipped because they contain `.jellyplex-sync-ignore` (see [Excluding folders](#excluding-folders)). A library folder in one of these lists means the run was stopped.
227
251
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
228
252
  - `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
229
253
  - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
@@ -295,8 +319,10 @@ This helper can also be used on other NAS systems or Linux servers — schedule
295
319
  - **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
296
320
  - **Asset folders** — Subdirectories (e.g., `other`, `interviews`) are processed recursively with the same materialization strategy. Note: rename your Jellyfin `extras` folder to `other`, since Plex does not recognize `extras`.
297
321
  - **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
298
- - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
322
+ - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed, except folders marked with `.jellyplex-sync-ignore` and folders that contain one. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
299
323
  - **Ignored items** — Entries the scanner couldn't classify (stray files at the library root, folders whose names don't parse) are reported in the summary and the `--json` `ignored` array. They are *not* carried over to the target — useful to verify before deleting the source.
324
+ - **Hidden files and folders are skipped.** Names starting with a dot (`.DS_Store`, `.stversions`, `.Trash`) are skipped at every level of the source library, by `import` as well. They do not appear in the list of ignored items.
325
+ - **Marked folders are skipped** on both sides, see [Excluding folders](#excluding-folders).
300
326
  - **Video-level clashes auto-resolve** — When two source files would translate to the same target name (a common P→J outcome: `[1080p].mkv` and `[1080p] [remux].mkv` both collapse to `- BD.mkv`), a short hash of the source filename gets appended (e.g. `Movie - BD [a3f7c819].mkv`). Both files get synced; spot the hash in the `plan` output or in `--json` via the per-file `disambiguation` field. Pre-0.3 the whole movie was skipped on clash.
301
327
  - **Folder-level clashes still abort** — Two source folders mapping to the same target folder name (e.g. via dropped `[bracket]` labels) is a structural error in the source library, not something to silently merge. The run is stopped with exit code 2, the clashing folders are listed, and you're asked to rename one side.
302
328
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror"
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror"
5
5
  authors = [{ name = "Stefan Schönberger", email = "stefan@sniner.dev" }]
6
6
  requires-python = ">=3.11"
@@ -12,9 +12,10 @@ without a side-channel to the Planner's reporter.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import pathlib
15
16
  from dataclasses import dataclass
16
17
 
17
- from .library import Drop, IgnoredEntry
18
+ from .library import Drop, IgnoredEntry, find_marked
18
19
  from .plan import Plan, collect_drops
19
20
 
20
21
 
@@ -41,11 +42,17 @@ class MovieOnlyInSource:
41
42
 
42
43
  @dataclass
43
44
  class DiffResult:
45
+ """`marked_in_source` and `marked_in_target` are the folders left out
46
+ of the comparison because they contain the ignore marker. They are
47
+ no difference: a sync leaves them alone as well."""
48
+
44
49
  movies_only_in_source: tuple[MovieOnlyInSource, ...] = ()
45
50
  movies_only_in_target: tuple[str, ...] = ()
46
51
  differing_movies: tuple[DiffEntry, ...] = ()
47
52
  drops: tuple[Drop, ...] = ()
48
53
  ignored: tuple[IgnoredEntry, ...] = ()
54
+ marked_in_source: tuple[pathlib.Path, ...] = ()
55
+ marked_in_target: tuple[pathlib.Path, ...] = ()
49
56
 
50
57
  @property
51
58
  def has_differences(self) -> bool:
@@ -62,11 +69,16 @@ def compare(plan: Plan) -> DiffResult:
62
69
  as the pre-0.3 diff implementation."""
63
70
  planned_folder_names = {m.target_folder.name for m in plan.movies}
64
71
  planned_by_name = {m.target_folder.name: m for m in plan.movies}
72
+ marked_folder_names = {
73
+ p.relative_to(plan.target_root).parts[0]
74
+ for p in plan.marked_in_target
75
+ if p != plan.target_root
76
+ }
65
77
 
66
78
  actual: dict[str, set[str]] = {}
67
79
  if plan.target_root.is_dir():
68
80
  for entry in plan.target_root.iterdir():
69
- if not entry.is_dir():
81
+ if not entry.is_dir() or entry.name in marked_folder_names:
70
82
  continue
71
83
  actual[entry.name] = {sub.name for sub in entry.iterdir()}
72
84
 
@@ -90,7 +102,15 @@ def compare(plan: Plan) -> DiffResult:
90
102
  | {a.folder_name for a in pm.assets}
91
103
  )
92
104
  src_only = tuple(sorted(expected_files - actual[name]))
93
- tgt_only = tuple(sorted(actual[name] - expected_files))
105
+ # Same rule as the Realizer: an entry that is or holds a marked
106
+ # folder would not be removed, so it is no difference.
107
+ tgt_only = tuple(
108
+ sorted(
109
+ sub
110
+ for sub in actual[name] - expected_files
111
+ if not _holds_marker(plan.target_root / name / sub)
112
+ )
113
+ )
94
114
  if src_only or tgt_only:
95
115
  differing.append(
96
116
  DiffEntry(
@@ -106,4 +126,10 @@ def compare(plan: Plan) -> DiffResult:
106
126
  differing_movies=tuple(differing),
107
127
  drops=collect_drops(plan),
108
128
  ignored=tuple(plan.ignored),
129
+ marked_in_source=plan.marked_in_source,
130
+ marked_in_target=plan.marked_in_target,
109
131
  )
132
+
133
+
134
+ def _holds_marker(path: pathlib.Path) -> bool:
135
+ return not path.is_symlink() and bool(find_marked(path))
@@ -19,7 +19,13 @@ from collections.abc import Iterable
19
19
  from dataclasses import dataclass
20
20
  from typing import Protocol
21
21
 
22
- from .library import ACCEPTED_VIDEO_SUFFIXES, IgnoredEntry, LibraryReader
22
+ from .library import (
23
+ ACCEPTED_VIDEO_SUFFIXES,
24
+ IgnoredEntry,
25
+ LibraryReader,
26
+ find_marked,
27
+ is_marked,
28
+ )
23
29
  from .model import MovieInfo
24
30
 
25
31
 
@@ -47,11 +53,16 @@ class DiscoveredGroup:
47
53
 
48
54
 
49
55
  class SourceDiscoverer(Protocol):
56
+ """Finds candidate groups below `root`. Entries it cannot use go to
57
+ `ignored`; directories it skips because they contain the ignore
58
+ marker go to `marked`, `root` included."""
59
+
50
60
  def discover(
51
61
  self,
52
62
  root: pathlib.Path,
53
63
  *,
54
64
  ignored: list[IgnoredEntry] | None = None,
65
+ marked: list[pathlib.Path] | None = None,
55
66
  ) -> Iterable[DiscoveredGroup]: ...
56
67
 
57
68
 
@@ -69,24 +80,39 @@ class TwoLevelDiscoverer:
69
80
  root: pathlib.Path,
70
81
  *,
71
82
  ignored: list[IgnoredEntry] | None = None,
83
+ marked: list[pathlib.Path] | None = None,
72
84
  ) -> Iterable[DiscoveredGroup]:
85
+ if is_marked(root):
86
+ if marked is not None:
87
+ marked.append(root)
88
+ return
73
89
  for entry in sorted(root.glob("*")):
90
+ if entry.name.startswith("."):
91
+ # OS/sync junk (.DS_Store, .stversions, .Trash, ...), skipped
92
+ # without a report at every level. The readers would accept
93
+ # such a name as a movie title.
94
+ continue
74
95
  if not entry.is_dir():
75
96
  if ignored is not None:
76
97
  ignored.append(IgnoredEntry(entry, "not a directory"))
77
98
  continue
99
+ if is_marked(entry):
100
+ if marked is not None:
101
+ marked.append(entry)
102
+ continue
78
103
  videos: list[pathlib.Path] = []
79
104
  assets: list[pathlib.Path] = []
80
105
  loose: list[pathlib.Path] = []
81
- for child in entry.glob("*"):
106
+ for child in sorted(entry.glob("*")):
82
107
  if child.name.startswith("."):
83
- # OS/sync junk (.DS_Store, .stversions, ...). Skipped
84
- # in both file and folder form, matching legacy behaviour.
85
108
  continue
86
109
  if child.is_file() and child.suffix.lower() in ACCEPTED_VIDEO_SUFFIXES:
87
110
  videos.append(child)
88
111
  elif child.is_file():
89
112
  loose.append(child)
113
+ elif child.is_dir() and is_marked(child):
114
+ if marked is not None:
115
+ marked.append(child)
90
116
  elif child.is_dir():
91
117
  assets.append(child)
92
118
  yield DiscoveredGroup(
@@ -137,15 +163,25 @@ class FlatDiscoverer:
137
163
  root: pathlib.Path,
138
164
  *,
139
165
  ignored: list[IgnoredEntry] | None = None,
166
+ marked: list[pathlib.Path] | None = None,
140
167
  ) -> Iterable[DiscoveredGroup]:
141
168
  groups: dict[_MovieKey, list[pathlib.Path]] = {}
142
169
  source_paths: dict[_MovieKey, pathlib.Path] = {}
143
170
  movies: dict[_MovieKey, MovieInfo] = {}
144
171
 
172
+ marked_dirs = self._marked_dirs(root)
173
+ if marked is not None:
174
+ marked.extend(sorted(marked_dirs))
175
+
145
176
  for path in sorted(root.rglob("*")):
146
177
  if not path.is_file():
147
178
  continue
148
- if path.name.startswith("."):
179
+ # Dot-files and everything below a dot-folder (.Trash,
180
+ # .stversions). Only the parts below root count: the staging
181
+ # root itself may lie inside a dot-folder.
182
+ if _is_hidden(path, root):
183
+ continue
184
+ if any(parent in marked_dirs for parent in path.parents):
149
185
  continue
150
186
  if path.suffix.lower() not in ACCEPTED_VIDEO_SUFFIXES:
151
187
  if ignored is not None:
@@ -174,3 +210,14 @@ class FlatDiscoverer:
174
210
  loose_files=(),
175
211
  movie=movies[key],
176
212
  )
213
+
214
+ @staticmethod
215
+ def _marked_dirs(root: pathlib.Path) -> set[pathlib.Path]:
216
+ """Marked directories at or below `root`. A marker inside a
217
+ dot-folder does not count: that folder is skipped anyway, and
218
+ reporting it would name something the user never sees."""
219
+ return {d for d in find_marked(root) if not _is_hidden(d, root)}
220
+
221
+
222
+ def _is_hidden(path: pathlib.Path, root: pathlib.Path) -> bool:
223
+ return any(part.startswith(".") for part in path.relative_to(root).parts)
@@ -34,6 +34,10 @@ def _ignored_payload(
34
34
  return [{"path": str(e.path), "name": e.path.name, "reason": e.reason} for e in entries]
35
35
 
36
36
 
37
+ def _paths_payload(paths: list[pathlib.Path] | tuple[pathlib.Path, ...]) -> list[str]:
38
+ return [str(p) for p in paths]
39
+
40
+
37
41
  def _drop_payload(d: Drop) -> dict[str, Any]:
38
42
  return {"kind": d.kind, "key": d.key, "value": d.value, "reason": d.reason}
39
43
 
@@ -97,8 +101,12 @@ def write_sync_json(
97
101
  "items_ignored": len(stats.ignored),
98
102
  "strays_in_target": len(stats.strays_in_target),
99
103
  "clashes": len(stats.clashes),
104
+ "marked_in_source": len(stats.marked_in_source),
105
+ "marked_in_target": len(stats.marked_in_target),
100
106
  },
101
107
  "ignored": _ignored_payload(stats.ignored),
108
+ "marked_in_source": _paths_payload(stats.marked_in_source),
109
+ "marked_in_target": _paths_payload(stats.marked_in_target),
102
110
  "strays_in_target": list(stats.strays_in_target),
103
111
  "translation_losses": _drops_payload(drops),
104
112
  "clashes": _clashes_payload(stats.clashes),
@@ -173,12 +181,16 @@ def write_plan_json(out: TextIO, plan: Plan) -> None:
173
181
  "movie_clashes": len(plan.clashes),
174
182
  "translation_losses": len(distinct_drops),
175
183
  "ignored": len(plan.ignored),
184
+ "marked_in_source": len(plan.marked_in_source),
185
+ "marked_in_target": len(plan.marked_in_target),
176
186
  },
177
187
  "movies": [_planned_movie_payload(m) for m in plan.movies],
178
188
  "folder_clashes": _folder_clashes_payload(plan.folder_clashes),
179
189
  "movie_clashes": _clashes_payload(list(plan.clashes)),
180
190
  "translation_losses": _drops_payload(drops),
181
191
  "ignored": _ignored_payload(list(plan.ignored)),
192
+ "marked_in_source": _paths_payload(plan.marked_in_source),
193
+ "marked_in_target": _paths_payload(plan.marked_in_target),
182
194
  }
183
195
  json.dump(payload, out, indent=2)
184
196
  out.write("\n")
@@ -218,6 +230,8 @@ def write_diff_json(
218
230
  ],
219
231
  "translation_losses": _drops_payload(result.drops),
220
232
  "ignored": _ignored_payload(result.ignored),
233
+ "marked_in_source": _paths_payload(result.marked_in_source),
234
+ "marked_in_target": _paths_payload(result.marked_in_target),
221
235
  }
222
236
  json.dump(payload, out, indent=2)
223
237
  out.write("\n")
@@ -12,6 +12,29 @@ log = logging.getLogger(__name__)
12
12
 
13
13
  ACCEPTED_VIDEO_SUFFIXES = {".mkv", ".m4v"}
14
14
 
15
+ # A directory containing this file is left out of every run, together with
16
+ # everything below it: in the source it is not read, in the target nothing
17
+ # is written into it or removed from it.
18
+ IGNORE_MARKER = ".jellyplex-sync-ignore"
19
+
20
+
21
+ def is_marked(folder: pathlib.Path) -> bool:
22
+ """True if `folder` itself contains the ignore marker."""
23
+ return (folder / IGNORE_MARKER).exists()
24
+
25
+
26
+ def find_marked(tree: pathlib.Path) -> list[pathlib.Path]:
27
+ """The outermost directories at or below `tree` that contain the
28
+ ignore marker, sorted. A marked directory inside another one is left
29
+ out, the outer one already covers it. Empty for a file or a missing
30
+ path."""
31
+ if not tree.is_dir():
32
+ return []
33
+ if is_marked(tree):
34
+ return [tree]
35
+ found = {m.parent for m in tree.rglob(IGNORE_MARKER)}
36
+ return sorted(d for d in found if not any(p in found for p in d.parents))
37
+
15
38
 
16
39
  # ---------------------------------------------------------------------------
17
40
  # Reporter: how Writers tell the caller about lossy decisions
@@ -5,9 +5,10 @@ would be created in the target library if the sync ran. It is the
5
5
  single source of truth for what the Realizer should do, what the
6
6
  Comparator should expect, and what the JSON output reports.
7
7
 
8
- Plans are pure data — building one never touches the target filesystem.
9
- That property is what makes them auditable, serialisable, comparable
10
- across runs, and safely shareable between phases.
8
+ Plans are pure data. Building one never writes to the target
9
+ filesystem; it reads the target only to find ignore markers. That
10
+ property is what makes them auditable, serialisable, comparable across
11
+ runs, and safely shareable between phases.
11
12
  """
12
13
 
13
14
  from __future__ import annotations
@@ -86,7 +87,17 @@ class Plan:
86
87
  `protected_folders` holds the target folder names of source movies
87
88
  that could not be planned at all (folder clashes, or every video
88
89
  unresolved) — they are absent from `movies`, but the Realizer must
89
- not treat an existing target folder of that name as a stray."""
90
+ not treat an existing target folder of that name as a stray.
91
+
92
+ `marked_in_source` and `marked_in_target` hold the directories left
93
+ out because they contain the ignore marker. On the source side these
94
+ were not read. On the target side nothing is planned into them, and
95
+ the Realizer must neither remove them nor count them as strays. The
96
+ target list covers the library level (marked folders, and marked
97
+ folders anywhere inside a folder no source movie maps to) and the
98
+ asset folders the Plan would have written into. Markers deeper
99
+ inside a planned movie folder are not listed; the Realizer checks
100
+ for them before it removes anything."""
90
101
 
91
102
  source_root: pathlib.Path
92
103
  target_root: pathlib.Path
@@ -97,6 +108,19 @@ class Plan:
97
108
  clashes: tuple[MovieClash, ...] = ()
98
109
  folder_clashes: tuple[FolderClash, ...] = ()
99
110
  protected_folders: tuple[str, ...] = ()
111
+ marked_in_source: tuple[pathlib.Path, ...] = ()
112
+ marked_in_target: tuple[pathlib.Path, ...] = ()
113
+
114
+ @property
115
+ def locked_root(self) -> pathlib.Path | None:
116
+ """The library root that contains the ignore marker, if any. A
117
+ marked root locks the whole run: the Plan is empty, and acting on
118
+ it must not change the target, not even with --delete."""
119
+ if self.source_root in self.marked_in_source:
120
+ return self.source_root
121
+ if self.target_root in self.marked_in_target:
122
+ return self.target_root
123
+ return None
100
124
 
101
125
 
102
126
  def collect_drops(plan: Plan) -> tuple[Drop, ...]:
@@ -2,8 +2,9 @@
2
2
 
3
3
  The Planner is a pure function (modulo Reader I/O for `parse_movie` /
4
4
  `parse_video`): it reads the source tree, produces an immutable Plan,
5
- and never touches the target filesystem. The `Realizer` (next module)
6
- is the only layer that turns a Plan into actions on disk.
5
+ and never writes to the target filesystem. It reads the target only to
6
+ find ignore markers. The `Realizer` (next module) is the only layer
7
+ that turns a Plan into actions on disk.
7
8
 
8
9
  This separation is what makes the new pipeline auditable: the same
9
10
  Plan can be inspected (`plan` subcommand), compared against an existing
@@ -15,6 +16,7 @@ from __future__ import annotations
15
16
  import logging
16
17
  import pathlib
17
18
  from collections import defaultdict
19
+ from collections.abc import Collection
18
20
  from dataclasses import dataclass
19
21
 
20
22
  from .disambig import (
@@ -33,6 +35,8 @@ from .library import (
33
35
  LoggingReporter,
34
36
  MovieClash,
35
37
  Reporter,
38
+ find_marked,
39
+ is_marked,
36
40
  )
37
41
  from .model import MovieInfo
38
42
  from .plan import Plan, PlannedAsset, PlannedFile, PlannedMovie
@@ -81,15 +85,24 @@ class Planner:
81
85
  if source_root == target_root:
82
86
  raise ValueError("Cannot plan a library into itself")
83
87
 
88
+ locked = self._locked_plan(source_root, target_root)
89
+ if locked is not None:
90
+ return locked
91
+
84
92
  ignored: list[IgnoredEntry] = []
85
93
  clashes: list[MovieClash] = []
94
+ marked_src: list[pathlib.Path] = []
86
95
 
87
- candidates_by_target = self._group_candidates(source_root, ignored)
96
+ candidates_by_target = self._group_candidates(source_root, ignored, marked_src)
97
+ marked_tgt = self._marked_in_target(target_root, candidates_by_target.keys())
98
+ marked_movie_folders = {p for p in marked_tgt if p.parent == target_root}
88
99
  planned_movies: list[PlannedMovie] = []
89
100
  folder_clashes: list[FolderClash] = []
90
101
  protected_folders: list[str] = []
91
102
 
92
103
  for target_name, items in candidates_by_target.items():
104
+ if target_root / target_name in marked_movie_folders:
105
+ continue
93
106
  if len(items) > 1:
94
107
  folder_clashes.append(
95
108
  FolderClash(
@@ -100,7 +113,12 @@ class Planner:
100
113
  protected_folders.append(target_name)
101
114
  continue
102
115
  (candidate,) = items
103
- pm, movie_clashes = self._build_planned_movie(candidate, target_root / target_name)
116
+ pm, movie_clashes = self._build_planned_movie(
117
+ candidate,
118
+ target_root / target_name,
119
+ marked_src,
120
+ marked_tgt,
121
+ )
104
122
  clashes.extend(movie_clashes)
105
123
  if pm is not None:
106
124
  planned_movies.append(pm)
@@ -120,22 +138,66 @@ class Planner:
120
138
  clashes=tuple(clashes),
121
139
  folder_clashes=tuple(folder_clashes),
122
140
  protected_folders=tuple(protected_folders),
141
+ marked_in_source=tuple(sorted(marked_src)),
142
+ marked_in_target=tuple(sorted(marked_tgt)),
123
143
  )
124
144
 
125
145
  # ------------------------------------------------------------------
126
146
  # internals
127
147
  # ------------------------------------------------------------------
128
148
 
149
+ def _locked_plan(self, source_root: pathlib.Path, target_root: pathlib.Path) -> Plan | None:
150
+ """An empty Plan if either library root is marked, else None.
151
+ Checked before anything is scanned: a marked source root must not
152
+ read as an empty library, or --delete would clear the target."""
153
+ source_marked = is_marked(source_root)
154
+ target_marked = is_marked(target_root)
155
+ if not (source_marked or target_marked):
156
+ return None
157
+ return Plan(
158
+ source_root=source_root,
159
+ target_root=target_root,
160
+ source_format=self._reader.shortname(),
161
+ target_format=self._writer.shortname(),
162
+ marked_in_source=(source_root,) if source_marked else (),
163
+ marked_in_target=(target_root,) if target_marked else (),
164
+ )
165
+
166
+ def _marked_in_target(
167
+ self,
168
+ target_root: pathlib.Path,
169
+ mapped: Collection[str],
170
+ ) -> list[pathlib.Path]:
171
+ """Marked directories at the library level of the target. A folder
172
+ some source movie maps to counts only if it is marked itself;
173
+ inside it, the planned asset folders are checked while they are
174
+ built, and the Realizer checks the rest before removing anything.
175
+ In a folder no source movie maps to, a marker at any depth counts,
176
+ so that the folder is not reported as a stray."""
177
+ if not target_root.is_dir():
178
+ return []
179
+ found: list[pathlib.Path] = []
180
+ for entry in sorted(target_root.iterdir()):
181
+ if not entry.is_dir():
182
+ continue
183
+ if entry.name in mapped:
184
+ if is_marked(entry):
185
+ found.append(entry)
186
+ else:
187
+ found.extend(find_marked(entry))
188
+ return found
189
+
129
190
  def _group_candidates(
130
191
  self,
131
192
  source_root: pathlib.Path,
132
193
  ignored: list[IgnoredEntry],
194
+ marked: list[pathlib.Path],
133
195
  ) -> dict[str, list[_Candidate]]:
134
196
  """Run discovery + parse_movie, group surviving candidates by the
135
197
  target folder name the Writer would produce. Candidates whose
136
198
  folder name doesn't parse are added to `ignored`."""
137
199
  grouped: dict[str, list[_Candidate]] = defaultdict(list)
138
- for group in self._discoverer.discover(source_root, ignored=ignored):
200
+ for group in self._discoverer.discover(source_root, ignored=ignored, marked=marked):
139
201
  # A discoverer that parsed names to find group boundaries
140
202
  # ships the identity with the group; only folder-based
141
203
  # discovery leaves the parsing to us.
@@ -163,6 +225,8 @@ class Planner:
163
225
  self,
164
226
  candidate: _Candidate,
165
227
  target_folder: pathlib.Path,
228
+ marked_src: list[pathlib.Path],
229
+ marked_tgt: list[pathlib.Path],
166
230
  ) -> tuple[PlannedMovie | None, list[MovieClash]]:
167
231
  videos_info = [(self._reader.parse_video(p), p) for p in candidate.group.video_files]
168
232
 
@@ -191,7 +255,11 @@ class Planner:
191
255
  PlannedFile(source=p, target_name=p.name) for p in candidate.group.loose_files
192
256
  )
193
257
 
194
- planned_assets = tuple(self._build_planned_asset(p) for p in candidate.group.asset_dirs)
258
+ planned_assets: list[PlannedAsset] = []
259
+ for p in candidate.group.asset_dirs:
260
+ asset = self._build_planned_asset(p, target_folder / p.name, marked_src, marked_tgt)
261
+ if asset is not None:
262
+ planned_assets.append(asset)
195
263
 
196
264
  # All-clashes corner case: every video in this folder ended up
197
265
  # unresolved, and there's nothing else to sync. Returning None
@@ -206,7 +274,7 @@ class Planner:
206
274
  movie=candidate.movie,
207
275
  videos=planned_videos,
208
276
  loose_files=planned_loose,
209
- assets=planned_assets,
277
+ assets=tuple(planned_assets),
210
278
  folder_drops=candidate.folder_drops,
211
279
  protected_files=tuple(c.target_filename for c in dis_result.unresolved),
212
280
  )
@@ -221,14 +289,35 @@ class Planner:
221
289
  for drop in pf.drops:
222
290
  self._reporter.drop(drop)
223
291
 
224
- def _build_planned_asset(self, asset_dir: pathlib.Path) -> PlannedAsset:
292
+ def _build_planned_asset(
293
+ self,
294
+ asset_dir: pathlib.Path,
295
+ target_dir: pathlib.Path,
296
+ marked_src: list[pathlib.Path],
297
+ marked_tgt: list[pathlib.Path],
298
+ ) -> PlannedAsset | None:
299
+ """Plan one source asset folder into `target_dir`, recursively.
300
+ None if `target_dir` is marked: nothing gets written into it.
301
+ Marked source subfolders are left out and recorded."""
302
+ if is_marked(target_dir):
303
+ marked_tgt.append(target_dir)
304
+ return None
225
305
  files: list[PlannedFile] = []
226
306
  subfolders: list[PlannedAsset] = []
227
307
  for child in sorted(asset_dir.glob("*")):
228
308
  if child.name.startswith("."):
229
309
  continue
230
- if child.is_dir():
231
- subfolders.append(self._build_planned_asset(child))
310
+ if child.is_dir() and is_marked(child):
311
+ marked_src.append(child)
312
+ elif child.is_dir():
313
+ sub = self._build_planned_asset(
314
+ child,
315
+ target_dir / child.name,
316
+ marked_src,
317
+ marked_tgt,
318
+ )
319
+ if sub is not None:
320
+ subfolders.append(sub)
232
321
  elif child.is_file():
233
322
  files.append(PlannedFile(source=child, target_name=child.name))
234
323
  return PlannedAsset(
@@ -13,13 +13,42 @@ import pathlib
13
13
  from dataclasses import dataclass, field
14
14
 
15
15
  from . import utils
16
- from .library import FileEvent
16
+ from .library import IGNORE_MARKER, FileEvent, find_marked
17
17
  from .materializer import FileMaterializer, HardlinkMaterializer
18
18
  from .plan import Plan, PlannedAsset, PlannedFile, PlannedMovie
19
19
 
20
20
  log = logging.getLogger(__name__)
21
21
 
22
22
 
23
+ @dataclass(frozen=True)
24
+ class _MarkerGuard:
25
+ """Keeps every removal away from marked target folders."""
26
+
27
+ target_root: pathlib.Path
28
+ marked: frozenset[pathlib.Path]
29
+
30
+ def keeps(self, entry: pathlib.Path) -> bool:
31
+ """True if removing `entry` would remove a marked folder."""
32
+ if entry in self.marked:
33
+ # Listed in the Plan and reported with the summary.
34
+ return True
35
+ # A symlink is only unlinked; whatever it points to stays.
36
+ found = [] if entry.is_symlink() else find_marked(entry)
37
+ if not found:
38
+ return False
39
+ rel = entry.relative_to(self.target_root)
40
+ if found[0] == entry:
41
+ log.info("Keeping '%s', it contains %s", rel, IGNORE_MARKER)
42
+ else:
43
+ log.info(
44
+ "Keeping '%s', '%s' in it contains %s",
45
+ rel,
46
+ found[0].relative_to(entry),
47
+ IGNORE_MARKER,
48
+ )
49
+ return True
50
+
51
+
23
52
  @dataclass
24
53
  class RealizeStats:
25
54
  """Aggregate counters and events from one Realizer.apply() call.
@@ -53,7 +82,10 @@ class Realizer:
53
82
  raise ValueError(f"Target directory '{plan.target_root}' does not exist")
54
83
 
55
84
  stats = stats or RealizeStats()
85
+ if plan.locked_root is not None:
86
+ return stats
56
87
  stats.ignored_count += len(plan.ignored)
88
+ guard = _MarkerGuard(plan.target_root, frozenset(plan.marked_in_target))
57
89
 
58
90
  for movie in plan.movies:
59
91
  self._realize_movie(
@@ -62,6 +94,7 @@ class Realizer:
62
94
  delete=delete,
63
95
  verbose=verbose,
64
96
  stats=stats,
97
+ guard=guard,
65
98
  )
66
99
  stats.movies_processed += 1
67
100
 
@@ -71,8 +104,12 @@ class Realizer:
71
104
  # dry-run output without the planned folder masking it).
72
105
  # Folders of clash-skipped movies are not strays: they belong to a
73
106
  # source movie, so "skipped" must mean their target stays untouched.
107
+ # The same holds for folders that are marked or hold a marked folder.
74
108
  planned_folder_names = {m.target_folder.name for m in plan.movies}
75
109
  protected_folder_names = set(plan.protected_folders)
110
+ protected_folder_names.update(
111
+ p.relative_to(plan.target_root).parts[0] for p in plan.marked_in_target
112
+ )
76
113
  for entry in sorted(plan.target_root.iterdir()):
77
114
  if entry.name in planned_folder_names:
78
115
  continue
@@ -85,6 +122,7 @@ class Realizer:
85
122
  dry_run=dry_run,
86
123
  context="library_stray",
87
124
  stats=stats,
125
+ guard=guard,
88
126
  )
89
127
 
90
128
  return stats
@@ -101,6 +139,7 @@ class Realizer:
101
139
  delete: bool,
102
140
  verbose: bool,
103
141
  stats: RealizeStats,
142
+ guard: _MarkerGuard,
104
143
  ) -> None:
105
144
  self._ensure_dir(movie.target_folder, dry_run=dry_run)
106
145
 
@@ -131,6 +170,7 @@ class Realizer:
131
170
  dry_run=dry_run,
132
171
  context="movie_stray",
133
172
  stats=stats,
173
+ guard=guard,
134
174
  )
135
175
 
136
176
  for asset in movie.assets:
@@ -141,6 +181,7 @@ class Realizer:
141
181
  delete=delete,
142
182
  verbose=verbose,
143
183
  stats=stats,
184
+ guard=guard,
144
185
  )
145
186
 
146
187
  def _realize_asset(
@@ -152,6 +193,7 @@ class Realizer:
152
193
  delete: bool,
153
194
  verbose: bool,
154
195
  stats: RealizeStats,
196
+ guard: _MarkerGuard,
155
197
  ) -> None:
156
198
  self._ensure_dir(target_path, dry_run=dry_run)
157
199
 
@@ -170,6 +212,7 @@ class Realizer:
170
212
  dry_run=dry_run,
171
213
  context="asset_stray",
172
214
  stats=stats,
215
+ guard=guard,
173
216
  )
174
217
 
175
218
  for subfolder in asset.subfolders:
@@ -180,6 +223,7 @@ class Realizer:
180
223
  delete=delete,
181
224
  verbose=verbose,
182
225
  stats=stats,
226
+ guard=guard,
183
227
  )
184
228
 
185
229
  def _materialize_file(
@@ -215,7 +259,10 @@ class Realizer:
215
259
  dry_run: bool,
216
260
  context: str,
217
261
  stats: RealizeStats,
262
+ guard: _MarkerGuard,
218
263
  ) -> None:
264
+ if guard.keeps(entry):
265
+ return
219
266
  if dry_run:
220
267
  log.info("DELETE %s", entry)
221
268
  else:
@@ -10,7 +10,7 @@ import pathlib
10
10
  from typing import TextIO
11
11
 
12
12
  from .compare import DiffResult
13
- from .library import Drop, dedupe_drops
13
+ from .library import IGNORE_MARKER, Drop, dedupe_drops
14
14
  from .plan import Plan, PlannedAsset, collect_drops
15
15
 
16
16
 
@@ -60,6 +60,14 @@ def print_diff(
60
60
  print(f" ! '{i.path.name}': {i.reason}", file=out)
61
61
  print(file=out)
62
62
 
63
+ _print_marked(
64
+ result.marked_in_source,
65
+ result.marked_in_target,
66
+ source_path.resolve(),
67
+ target_path.resolve(),
68
+ out,
69
+ )
70
+
63
71
  if not result.has_differences:
64
72
  print("In sync. No differences found.", file=out)
65
73
 
@@ -73,6 +81,15 @@ def print_plan(plan: Plan, out: TextIO) -> None:
73
81
  )
74
82
  print(file=out)
75
83
 
84
+ if plan.locked_root is not None:
85
+ side = "source" if plan.locked_root == plan.source_root else "target"
86
+ print(
87
+ f"Empty plan: the {side} library contains {IGNORE_MARKER}, a sync would "
88
+ "change nothing. Delete that file to include the library.",
89
+ file=out,
90
+ )
91
+ return
92
+
76
93
  if plan.movies:
77
94
  print(f"Movies ({len(plan.movies)}):", file=out)
78
95
  for m in plan.movies:
@@ -116,6 +133,14 @@ def print_plan(plan: Plan, out: TextIO) -> None:
116
133
  print(f" ! '{i.path.name}': {i.reason}", file=out)
117
134
  print(file=out)
118
135
 
136
+ _print_marked(
137
+ plan.marked_in_source,
138
+ plan.marked_in_target,
139
+ plan.source_root,
140
+ plan.target_root,
141
+ out,
142
+ )
143
+
119
144
  if not (plan.movies or plan.folder_clashes or plan.clashes or plan.ignored):
120
145
  print("Empty plan. Nothing to sync.", file=out)
121
146
 
@@ -131,5 +156,23 @@ def _print_drops(drops: tuple[Drop, ...], out: TextIO) -> None:
131
156
  print(file=out)
132
157
 
133
158
 
159
+ def _print_marked(
160
+ in_source: tuple[pathlib.Path, ...],
161
+ in_target: tuple[pathlib.Path, ...],
162
+ source_root: pathlib.Path,
163
+ target_root: pathlib.Path,
164
+ out: TextIO,
165
+ ) -> None:
166
+ count = len(in_source) + len(in_target)
167
+ if not count:
168
+ return
169
+ print(f"Skipped because they contain {IGNORE_MARKER} ({count}):", file=out)
170
+ for path in in_source:
171
+ print(f" source '{path.relative_to(source_root)}'", file=out)
172
+ for path in in_target:
173
+ print(f" target '{path.relative_to(target_root)}'", file=out)
174
+ print(file=out)
175
+
176
+
134
177
  def _count_asset_files(asset: PlannedAsset) -> int:
135
178
  return len(asset.files) + sum(_count_asset_files(sf) for sf in asset.subfolders)
@@ -25,6 +25,7 @@ from .formats import guess_library_type as guess_library_type
25
25
  from .formats import resolve_endpoints
26
26
  from .json_output import write_diff_json, write_plan_json
27
27
  from .library import (
28
+ IGNORE_MARKER,
28
29
  Drop,
29
30
  FileEvent,
30
31
  IgnoredEntry,
@@ -32,9 +33,10 @@ from .library import (
32
33
  MovieClash,
33
34
  NullReporter,
34
35
  Reporter,
36
+ is_marked,
35
37
  )
36
38
  from .materializer import FileMaterializer, HardlinkMaterializer, MoveMaterializer
37
- from .plan import collect_drops
39
+ from .plan import Plan, collect_drops
38
40
  from .planner import Planner
39
41
  from .realize import Realizer, RealizeStats
40
42
  from .report import print_diff, print_plan
@@ -60,6 +62,8 @@ class LibraryStats:
60
62
  strays_in_target: list[str] = field(default_factory=list)
61
63
  events: list[FileEvent] = field(default_factory=list)
62
64
  clashes: list[MovieClash] = field(default_factory=list)
65
+ marked_in_source: list[pathlib.Path] = field(default_factory=list)
66
+ marked_in_target: list[pathlib.Path] = field(default_factory=list)
63
67
 
64
68
 
65
69
  @dataclass
@@ -125,6 +129,19 @@ def sync(
125
129
  target_short.capitalize(),
126
130
  )
127
131
 
132
+ if _stop_at_marked_root(
133
+ source_reader.base_dir,
134
+ target_writer.base_dir,
135
+ lib_stats,
136
+ "synced",
137
+ ):
138
+ return SyncResult(
139
+ exit_code=_EXIT_OK,
140
+ source_format=source_short,
141
+ target_format=target_short,
142
+ stats=lib_stats,
143
+ )
144
+
128
145
  if not target_writer.base_dir.is_dir():
129
146
  if create:
130
147
  target_writer.base_dir.mkdir(parents=True)
@@ -210,6 +227,8 @@ def sync(
210
227
  lib_stats.strays_in_target.extend(realize_stats.strays_in_target)
211
228
  lib_stats.events.extend(realize_stats.events)
212
229
  lib_stats.clashes.extend(plan.clashes)
230
+ lib_stats.marked_in_source.extend(plan.marked_in_source)
231
+ lib_stats.marked_in_target.extend(plan.marked_in_target)
213
232
 
214
233
  ignored_count = len(lib_stats.ignored)
215
234
  stray_count = len(lib_stats.strays_in_target)
@@ -232,6 +251,8 @@ def sync(
232
251
  for item in lib_stats.ignored:
233
252
  log.info(" '%s' (%s)", item.path.name, item.reason)
234
253
 
254
+ _log_marked(plan, verbose=verbose)
255
+
235
256
  if strays_kept:
236
257
  log.warning(
237
258
  "%d item(s) in target are not in the source library. "
@@ -278,8 +299,9 @@ def diff(
278
299
 
279
300
  Read-only: never touches the filesystem. Exit codes follow the Unix
280
301
  `diff` convention — 0 if no differences, 1 if differences are found,
281
- 2 if there's a setup error. With `as_json=True`, emits the machine-
282
- readable JSON document instead of the human-readable text report.
302
+ 2 if there's a setup error or a library root contains the ignore
303
+ marker. With `as_json=True`, emits the machine-readable JSON document
304
+ instead of the human-readable text report.
283
305
  """
284
306
  stream: TextIO = out if out is not None else sys.stdout
285
307
  source_path = pathlib.Path(source)
@@ -293,6 +315,21 @@ def diff(
293
315
  log.error("Target directory '%s' does not exist", endpoints.target_writer.base_dir)
294
316
  return 2
295
317
 
318
+ marked = _find_marked_root(
319
+ endpoints.source_reader.base_dir,
320
+ endpoints.target_writer.base_dir,
321
+ )
322
+ if marked is not None:
323
+ side, root = marked
324
+ log.error(
325
+ "Not compared: the %s library '%s' contains %s. Delete that file to include "
326
+ "the library",
327
+ side,
328
+ root,
329
+ IGNORE_MARKER,
330
+ )
331
+ return 2
332
+
296
333
  # 0.3 pipeline: Planner.plan() + compare(plan). The Plan records the
297
334
  # translation drops itself, so compare() carries them into the
298
335
  # DiffResult — no reporter side-channel needed.
@@ -415,6 +452,19 @@ def import_media(
415
452
  target_short.capitalize(),
416
453
  )
417
454
 
455
+ if _stop_at_marked_root(
456
+ source_reader.base_dir,
457
+ target_writer.base_dir,
458
+ lib_stats,
459
+ "imported",
460
+ ):
461
+ return SyncResult(
462
+ exit_code=_EXIT_OK,
463
+ source_format=source_short,
464
+ target_format=target_short,
465
+ stats=lib_stats,
466
+ )
467
+
418
468
  if not target_writer.base_dir.is_dir():
419
469
  if create:
420
470
  target_writer.base_dir.mkdir(parents=True)
@@ -450,6 +500,8 @@ def import_media(
450
500
  lib_stats.ignored.extend(plan.ignored)
451
501
  lib_stats.events.extend(realize_stats.events)
452
502
  lib_stats.clashes.extend(plan.clashes)
503
+ lib_stats.marked_in_source.extend(plan.marked_in_source)
504
+ lib_stats.marked_in_target.extend(plan.marked_in_target)
453
505
 
454
506
  verb = "moved" if isinstance(materializer, MoveMaterializer) else "copied"
455
507
  summary = (
@@ -465,6 +517,8 @@ def import_media(
465
517
  for item in lib_stats.ignored:
466
518
  log.info(" '%s' (%s)", item.path.name, item.reason)
467
519
 
520
+ _log_marked(plan, verbose=verbose)
521
+
468
522
  if lib_stats.clashes:
469
523
  log.warning(
470
524
  "%d movie(s) skipped because two or more source files map to the "
@@ -479,3 +533,61 @@ def import_media(
479
533
  stats=lib_stats,
480
534
  drops=collect_drops(plan),
481
535
  )
536
+
537
+
538
+ def _find_marked_root(
539
+ source_root: pathlib.Path,
540
+ target_root: pathlib.Path,
541
+ ) -> tuple[str, pathlib.Path] | None:
542
+ """The first library root that contains the ignore marker, with the
543
+ side it is on."""
544
+ for side, root in (("source", source_root), ("target", target_root)):
545
+ if is_marked(root):
546
+ return side, root
547
+ return None
548
+
549
+
550
+ def _stop_at_marked_root(
551
+ source_root: pathlib.Path,
552
+ target_root: pathlib.Path,
553
+ stats: LibraryStats,
554
+ verb: str,
555
+ ) -> bool:
556
+ """Check both library roots for the ignore marker before anything is
557
+ scanned or created. A marked root stops the run; it is recorded in
558
+ `stats` and reported. Returns True if the run has to stop."""
559
+ marked = _find_marked_root(source_root, target_root)
560
+ if marked is None:
561
+ return False
562
+ side, root = marked
563
+ if side == "source":
564
+ stats.marked_in_source.append(root)
565
+ else:
566
+ stats.marked_in_target.append(root)
567
+ log.warning(
568
+ "Nothing was %s: the %s library '%s' contains %s. Delete that file to include "
569
+ "the library",
570
+ verb,
571
+ side,
572
+ root,
573
+ IGNORE_MARKER,
574
+ )
575
+ return True
576
+
577
+
578
+ def _log_marked(plan: Plan, *, verbose: bool) -> None:
579
+ count = len(plan.marked_in_source) + len(plan.marked_in_target)
580
+ if not count:
581
+ return
582
+ if not verbose:
583
+ log.info(
584
+ "%d folder(s) skipped because they contain %s. Pass --verbose to list them",
585
+ count,
586
+ IGNORE_MARKER,
587
+ )
588
+ return
589
+ log.info("%d folder(s) skipped because they contain %s:", count, IGNORE_MARKER)
590
+ for path in plan.marked_in_source:
591
+ log.info(" source '%s'", path.relative_to(plan.source_root))
592
+ for path in plan.marked_in_target:
593
+ log.info(" target '%s'", path.relative_to(plan.target_root))
File without changes