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.
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/PKG-INFO +32 -6
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/README.md +31 -5
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/pyproject.toml +1 -1
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/pyproject.toml.orig +1 -1
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/compare.py +29 -3
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/discover.py +52 -5
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/json_output.py +14 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/library.py +23 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/plan.py +28 -4
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/planner.py +99 -10
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/realize.py +48 -1
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/report.py +44 -1
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/sync.py +115 -3
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/LICENSE +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/__init__.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/main.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/sync.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/disambig.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/formats.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/jellyfin.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/materializer.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/plex.py +0 -0
- {jellyplex_sync-0.4.0 → jellyplex_sync-0.5.0}/src/jellyplex_sync/py.typed +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
6
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|