jellyplex-gen 0.1.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_gen-0.1.0/LICENSE +27 -0
- jellyplex_gen-0.1.0/PKG-INFO +83 -0
- jellyplex_gen-0.1.0/README.md +67 -0
- jellyplex_gen-0.1.0/pyproject.toml +53 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/__init__.py +22 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/cli/__init__.py +0 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/cli/main.py +77 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/generator.py +134 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/manifest.py +70 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/naming.py +55 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/seed.py +27 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/structure.py +18 -0
- jellyplex_gen-0.1.0/src/jellyplex_gen/titles.py +76 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Copyright (c) 2026, Stefan Schönberger <stefan@sniner.dev>
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without
|
|
5
|
+
modification, are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
11
|
+
this list of conditions and the following disclaimer in the documentation
|
|
12
|
+
and/or other materials provided with the distribution.
|
|
13
|
+
|
|
14
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
15
|
+
may be used to endorse or promote products derived from this software
|
|
16
|
+
without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
19
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
20
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
22
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
23
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
24
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
25
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
26
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jellyplex-gen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate plausible test media libraries in Plex or Jellyfin format
|
|
5
|
+
Author: Stefan Schönberger
|
|
6
|
+
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# jellyplex-gen
|
|
18
|
+
|
|
19
|
+
Generate plausible test media libraries in Plex or Jellyfin format.
|
|
20
|
+
|
|
21
|
+
A sibling project to [jellyplex-sync](https://github.com/sniner/jellyplex-sync):
|
|
22
|
+
where `jellyplex-sync` converts between media library formats, `jellyplex-gen`
|
|
23
|
+
produces realistic-looking source material to test it (or anything else that
|
|
24
|
+
operates on Plex/Jellyfin libraries) against.
|
|
25
|
+
|
|
26
|
+
Generated libraries consist of placeholder files (0-byte by default, or
|
|
27
|
+
pseudorandom bytes up to a configurable per-file maximum) arranged according
|
|
28
|
+
to the chosen format's naming conventions. A format-neutral JSON manifest is
|
|
29
|
+
written alongside, describing the logical content of the library.
|
|
30
|
+
|
|
31
|
+
## Status
|
|
32
|
+
|
|
33
|
+
Early. Plex format only, flat hierarchy, single resolution variant per movie.
|
|
34
|
+
Designed so other formats and structures can be added without touching the
|
|
35
|
+
existing code paths.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv run jellyplex-gen plex --seed=hello --movies=50 --out=./testlib
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The same `--seed` always produces the same library. Seeds are arbitrary
|
|
44
|
+
alphanumeric strings — they get hashed to an integer internally, so
|
|
45
|
+
reproducibility doesn't depend on Python's `PYTHONHASHSEED`.
|
|
46
|
+
|
|
47
|
+
To get just the manifest (no files written):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv run jellyplex-gen manifest --seed=hello --movies=50
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For copy-semantics or content-aware testing, fill files with up to N bytes
|
|
54
|
+
of pseudorandom content (default is 0, i.e. empty files):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv run jellyplex-gen plex --seed=hello --movies=20 --max-content-size=4096 --out=./testlib
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
File contents are seeded per-file from the manifest seed and the file's
|
|
61
|
+
relative path, so the same `--seed` + same `--max-content-size` always
|
|
62
|
+
yield byte-identical libraries.
|
|
63
|
+
|
|
64
|
+
## Architecture
|
|
65
|
+
|
|
66
|
+
The generator is built around three swappable abstractions:
|
|
67
|
+
|
|
68
|
+
- **`TitleSource`** — supplies movie titles. The default
|
|
69
|
+
`CuratedTitleSource` mixes word pools and hand-crafted titles. The
|
|
70
|
+
protocol leaves room for other sources without touching anything else.
|
|
71
|
+
- **`NamingConvention`** — format-specific: turns a logical movie into
|
|
72
|
+
directory and file names. Currently only `PlexNamingConvention`.
|
|
73
|
+
- **`LibraryStructure`** — orthogonal to format: decides the parent path
|
|
74
|
+
of each movie directory. Currently only `FlatLibraryStructure`.
|
|
75
|
+
|
|
76
|
+
Generation is two-phase: `build_manifest()` produces a format-neutral
|
|
77
|
+
description, `materialize()` writes the actual filesystem layout. The
|
|
78
|
+
manifest can be saved, loaded, and diffed independently of any concrete
|
|
79
|
+
layout.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
BSD-3-Clause. See `LICENSE`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# jellyplex-gen
|
|
2
|
+
|
|
3
|
+
Generate plausible test media libraries in Plex or Jellyfin format.
|
|
4
|
+
|
|
5
|
+
A sibling project to [jellyplex-sync](https://github.com/sniner/jellyplex-sync):
|
|
6
|
+
where `jellyplex-sync` converts between media library formats, `jellyplex-gen`
|
|
7
|
+
produces realistic-looking source material to test it (or anything else that
|
|
8
|
+
operates on Plex/Jellyfin libraries) against.
|
|
9
|
+
|
|
10
|
+
Generated libraries consist of placeholder files (0-byte by default, or
|
|
11
|
+
pseudorandom bytes up to a configurable per-file maximum) arranged according
|
|
12
|
+
to the chosen format's naming conventions. A format-neutral JSON manifest is
|
|
13
|
+
written alongside, describing the logical content of the library.
|
|
14
|
+
|
|
15
|
+
## Status
|
|
16
|
+
|
|
17
|
+
Early. Plex format only, flat hierarchy, single resolution variant per movie.
|
|
18
|
+
Designed so other formats and structures can be added without touching the
|
|
19
|
+
existing code paths.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv run jellyplex-gen plex --seed=hello --movies=50 --out=./testlib
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The same `--seed` always produces the same library. Seeds are arbitrary
|
|
28
|
+
alphanumeric strings — they get hashed to an integer internally, so
|
|
29
|
+
reproducibility doesn't depend on Python's `PYTHONHASHSEED`.
|
|
30
|
+
|
|
31
|
+
To get just the manifest (no files written):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv run jellyplex-gen manifest --seed=hello --movies=50
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For copy-semantics or content-aware testing, fill files with up to N bytes
|
|
38
|
+
of pseudorandom content (default is 0, i.e. empty files):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run jellyplex-gen plex --seed=hello --movies=20 --max-content-size=4096 --out=./testlib
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
File contents are seeded per-file from the manifest seed and the file's
|
|
45
|
+
relative path, so the same `--seed` + same `--max-content-size` always
|
|
46
|
+
yield byte-identical libraries.
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
The generator is built around three swappable abstractions:
|
|
51
|
+
|
|
52
|
+
- **`TitleSource`** — supplies movie titles. The default
|
|
53
|
+
`CuratedTitleSource` mixes word pools and hand-crafted titles. The
|
|
54
|
+
protocol leaves room for other sources without touching anything else.
|
|
55
|
+
- **`NamingConvention`** — format-specific: turns a logical movie into
|
|
56
|
+
directory and file names. Currently only `PlexNamingConvention`.
|
|
57
|
+
- **`LibraryStructure`** — orthogonal to format: decides the parent path
|
|
58
|
+
of each movie directory. Currently only `FlatLibraryStructure`.
|
|
59
|
+
|
|
60
|
+
Generation is two-phase: `build_manifest()` produces a format-neutral
|
|
61
|
+
description, `materialize()` writes the actual filesystem layout. The
|
|
62
|
+
manifest can be saved, loaded, and diffed independently of any concrete
|
|
63
|
+
layout.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
BSD-3-Clause. See `LICENSE`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jellyplex-gen"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generate plausible test media libraries in Plex or Jellyfin format"
|
|
5
|
+
authors = [{ name = "Stefan Schönberger", email = "stefan@sniner.dev" }]
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
license = "BSD-3-Clause"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
]
|
|
17
|
+
dependencies = []
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
jellyplex-gen = "jellyplex_gen.cli.main:main"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=8.3.5,<9"]
|
|
24
|
+
|
|
25
|
+
[tool.uv]
|
|
26
|
+
default-groups = "all"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.10.4,<0.11.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
addopts = "-ra -q"
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.pyright]
|
|
37
|
+
typeCheckingMode = "standard"
|
|
38
|
+
useLibraryCodeForTypes = true
|
|
39
|
+
venvPath = "."
|
|
40
|
+
venv = ".venv"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 96
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
ignore = ["E402"]
|
|
47
|
+
per-file-ignores = { "__init__.py" = ["F401"] }
|
|
48
|
+
extend-select = [
|
|
49
|
+
"I", # isort
|
|
50
|
+
"UP", # pyupgrade
|
|
51
|
+
"SIM", # flake8-simplify
|
|
52
|
+
"B", # flake8-bugbear
|
|
53
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .generator import build_manifest, materialize
|
|
2
|
+
from .manifest import LogicalMovie, Manifest, Variant
|
|
3
|
+
from .naming import NamingConvention, PlexNamingConvention
|
|
4
|
+
from .seed import rng_from_seed, seed_to_int
|
|
5
|
+
from .structure import FlatLibraryStructure, LibraryStructure
|
|
6
|
+
from .titles import CuratedTitleSource, TitleSource
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CuratedTitleSource",
|
|
10
|
+
"FlatLibraryStructure",
|
|
11
|
+
"LibraryStructure",
|
|
12
|
+
"LogicalMovie",
|
|
13
|
+
"Manifest",
|
|
14
|
+
"NamingConvention",
|
|
15
|
+
"PlexNamingConvention",
|
|
16
|
+
"TitleSource",
|
|
17
|
+
"Variant",
|
|
18
|
+
"build_manifest",
|
|
19
|
+
"materialize",
|
|
20
|
+
"rng_from_seed",
|
|
21
|
+
"seed_to_int",
|
|
22
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..generator import build_manifest, materialize
|
|
8
|
+
from ..naming import PlexNamingConvention
|
|
9
|
+
from ..structure import FlatLibraryStructure
|
|
10
|
+
from ..titles import CuratedTitleSource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="jellyplex-gen",
|
|
16
|
+
description="Generate plausible test media libraries in Plex or Jellyfin format.",
|
|
17
|
+
)
|
|
18
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
plex = sub.add_parser("plex", help="Generate a Plex-format library on disk.")
|
|
21
|
+
_common_args(plex)
|
|
22
|
+
plex.add_argument("--out", type=Path, required=True, help="Output directory.")
|
|
23
|
+
plex.add_argument(
|
|
24
|
+
"--max-content-size",
|
|
25
|
+
type=int,
|
|
26
|
+
default=0,
|
|
27
|
+
help="Maximum per-file content size in bytes (default: 0 = empty files).",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
mf = sub.add_parser("manifest", help="Emit only the manifest JSON to stdout.")
|
|
31
|
+
_common_args(mf)
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
manifest = build_manifest(
|
|
36
|
+
seed=args.seed,
|
|
37
|
+
movie_count=args.movies,
|
|
38
|
+
title_source=CuratedTitleSource(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if args.command == "manifest":
|
|
42
|
+
sys.stdout.write(manifest.to_json() + "\n")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
out: Path = args.out
|
|
46
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
materialize(
|
|
48
|
+
manifest,
|
|
49
|
+
out,
|
|
50
|
+
naming=PlexNamingConvention(),
|
|
51
|
+
structure=FlatLibraryStructure(),
|
|
52
|
+
max_content_size=args.max_content_size,
|
|
53
|
+
)
|
|
54
|
+
manifest.save(out / "manifest.json")
|
|
55
|
+
print(
|
|
56
|
+
f"Generated {len(manifest.movies)} movies in {out} (seed={args.seed!r})",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _common_args(p: argparse.ArgumentParser) -> None:
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--seed",
|
|
64
|
+
type=str,
|
|
65
|
+
default="default",
|
|
66
|
+
help="Alphanumeric seed for reproducibility.",
|
|
67
|
+
)
|
|
68
|
+
p.add_argument(
|
|
69
|
+
"--movies",
|
|
70
|
+
type=int,
|
|
71
|
+
default=20,
|
|
72
|
+
help="Number of movies to generate.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .manifest import LogicalMovie, Manifest, Variant
|
|
7
|
+
from .naming import NamingConvention
|
|
8
|
+
from .seed import rng_from_seed
|
|
9
|
+
from .structure import LibraryStructure
|
|
10
|
+
from .titles import TitleSource
|
|
11
|
+
|
|
12
|
+
_RESOLUTIONS = ("480i", "576i", "720p", "1080p", "2160p")
|
|
13
|
+
_RESOLUTION_WEIGHTS = (1, 1, 2, 5, 3)
|
|
14
|
+
_EDITIONS = ("Director's Cut", "Extended", "Theatrical", "Remastered", "Unrated")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_manifest(
|
|
18
|
+
seed: str,
|
|
19
|
+
*,
|
|
20
|
+
movie_count: int,
|
|
21
|
+
title_source: TitleSource,
|
|
22
|
+
year_range: tuple[int, int] = (1960, 2024),
|
|
23
|
+
imdb_prob: float = 0.9,
|
|
24
|
+
variant_prob: float = 0.7,
|
|
25
|
+
edition_prob: float = 0.15,
|
|
26
|
+
nfo_prob: float = 0.3,
|
|
27
|
+
poster_prob: float = 0.5,
|
|
28
|
+
) -> Manifest:
|
|
29
|
+
rng = rng_from_seed(seed, salt="::content")
|
|
30
|
+
movies = tuple(
|
|
31
|
+
_make_movie(
|
|
32
|
+
rng,
|
|
33
|
+
title_source,
|
|
34
|
+
year_range=year_range,
|
|
35
|
+
imdb_prob=imdb_prob,
|
|
36
|
+
variant_prob=variant_prob,
|
|
37
|
+
edition_prob=edition_prob,
|
|
38
|
+
nfo_prob=nfo_prob,
|
|
39
|
+
poster_prob=poster_prob,
|
|
40
|
+
)
|
|
41
|
+
for _ in range(movie_count)
|
|
42
|
+
)
|
|
43
|
+
return Manifest(seed=seed, movies=movies)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def materialize(
|
|
47
|
+
manifest: Manifest,
|
|
48
|
+
output_root: Path,
|
|
49
|
+
*,
|
|
50
|
+
naming: NamingConvention,
|
|
51
|
+
structure: LibraryStructure,
|
|
52
|
+
max_content_size: int = 0,
|
|
53
|
+
) -> None:
|
|
54
|
+
structure_rng = rng_from_seed(manifest.seed, salt="::structure")
|
|
55
|
+
for movie in manifest.movies:
|
|
56
|
+
parent = structure.parent_path(movie, structure_rng)
|
|
57
|
+
dir_path = output_root / parent / naming.directory_name(movie)
|
|
58
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
variants: tuple[Variant | None, ...] = movie.variants or (None,)
|
|
60
|
+
for v in variants:
|
|
61
|
+
_write_file(
|
|
62
|
+
dir_path / naming.video_filename(movie, v),
|
|
63
|
+
output_root=output_root,
|
|
64
|
+
manifest_seed=manifest.seed,
|
|
65
|
+
max_size=max_content_size,
|
|
66
|
+
)
|
|
67
|
+
if movie.has_nfo:
|
|
68
|
+
_write_file(
|
|
69
|
+
dir_path / naming.sidecar_filename(movie, "nfo"),
|
|
70
|
+
output_root=output_root,
|
|
71
|
+
manifest_seed=manifest.seed,
|
|
72
|
+
max_size=max_content_size,
|
|
73
|
+
)
|
|
74
|
+
if movie.has_poster:
|
|
75
|
+
_write_file(
|
|
76
|
+
dir_path / naming.sidecar_filename(movie, "poster"),
|
|
77
|
+
output_root=output_root,
|
|
78
|
+
manifest_seed=manifest.seed,
|
|
79
|
+
max_size=max_content_size,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_file(
|
|
84
|
+
path: Path,
|
|
85
|
+
*,
|
|
86
|
+
output_root: Path,
|
|
87
|
+
manifest_seed: str,
|
|
88
|
+
max_size: int,
|
|
89
|
+
) -> None:
|
|
90
|
+
if max_size <= 0:
|
|
91
|
+
path.touch()
|
|
92
|
+
return
|
|
93
|
+
# Per-file RNG keyed on relative path so file order doesn't affect content.
|
|
94
|
+
rel = path.relative_to(output_root).as_posix()
|
|
95
|
+
rng = rng_from_seed(manifest_seed, salt=f"::content::{rel}")
|
|
96
|
+
size = rng.randint(0, max_size)
|
|
97
|
+
path.write_bytes(rng.randbytes(size))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _make_movie(
|
|
101
|
+
rng: random.Random,
|
|
102
|
+
title_source: TitleSource,
|
|
103
|
+
*,
|
|
104
|
+
year_range: tuple[int, int],
|
|
105
|
+
imdb_prob: float,
|
|
106
|
+
variant_prob: float,
|
|
107
|
+
edition_prob: float,
|
|
108
|
+
nfo_prob: float,
|
|
109
|
+
poster_prob: float,
|
|
110
|
+
) -> LogicalMovie:
|
|
111
|
+
title = title_source.random_title(rng)
|
|
112
|
+
year = rng.randint(*year_range)
|
|
113
|
+
imdb = _gen_imdb_id(rng) if rng.random() < imdb_prob else None
|
|
114
|
+
variants: tuple[Variant, ...] = ()
|
|
115
|
+
if rng.random() < variant_prob:
|
|
116
|
+
variants = (_gen_variant(rng, edition_prob=edition_prob),)
|
|
117
|
+
return LogicalMovie(
|
|
118
|
+
title=title,
|
|
119
|
+
year=year,
|
|
120
|
+
imdb_id=imdb,
|
|
121
|
+
variants=variants,
|
|
122
|
+
has_nfo=rng.random() < nfo_prob,
|
|
123
|
+
has_poster=rng.random() < poster_prob,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _gen_imdb_id(rng: random.Random) -> str:
|
|
128
|
+
return f"tt{rng.randint(1000000, 9999999)}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gen_variant(rng: random.Random, *, edition_prob: float) -> Variant:
|
|
132
|
+
resolution = rng.choices(_RESOLUTIONS, weights=_RESOLUTION_WEIGHTS)[0]
|
|
133
|
+
edition = rng.choice(_EDITIONS) if rng.random() < edition_prob else None
|
|
134
|
+
return Variant(resolution=resolution, edition=edition)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Variant:
|
|
11
|
+
resolution: str | None = None
|
|
12
|
+
edition: str | None = None
|
|
13
|
+
extras: tuple[str, ...] = ()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class LogicalMovie:
|
|
18
|
+
title: str
|
|
19
|
+
year: int
|
|
20
|
+
imdb_id: str | None = None
|
|
21
|
+
tmdb_id: str | None = None
|
|
22
|
+
variants: tuple[Variant, ...] = ()
|
|
23
|
+
has_nfo: bool = False
|
|
24
|
+
has_poster: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Manifest:
|
|
29
|
+
seed: str
|
|
30
|
+
movies: tuple[LogicalMovie, ...] = field(default_factory=tuple)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return asdict(self)
|
|
34
|
+
|
|
35
|
+
def to_json(self, *, indent: int | None = 2) -> str:
|
|
36
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict[str, Any]) -> Manifest:
|
|
40
|
+
movies = tuple(
|
|
41
|
+
LogicalMovie(
|
|
42
|
+
title=m["title"],
|
|
43
|
+
year=m["year"],
|
|
44
|
+
imdb_id=m.get("imdb_id"),
|
|
45
|
+
tmdb_id=m.get("tmdb_id"),
|
|
46
|
+
variants=tuple(
|
|
47
|
+
Variant(
|
|
48
|
+
resolution=v.get("resolution"),
|
|
49
|
+
edition=v.get("edition"),
|
|
50
|
+
extras=tuple(v.get("extras", ())),
|
|
51
|
+
)
|
|
52
|
+
for v in m.get("variants", ())
|
|
53
|
+
),
|
|
54
|
+
has_nfo=m.get("has_nfo", False),
|
|
55
|
+
has_poster=m.get("has_poster", False),
|
|
56
|
+
)
|
|
57
|
+
for m in data.get("movies", ())
|
|
58
|
+
)
|
|
59
|
+
return cls(seed=data["seed"], movies=movies)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_json(cls, text: str) -> Manifest:
|
|
63
|
+
return cls.from_dict(json.loads(text))
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def load(cls, path: Path) -> Manifest:
|
|
67
|
+
return cls.from_json(path.read_text(encoding="utf-8"))
|
|
68
|
+
|
|
69
|
+
def save(self, path: Path) -> None:
|
|
70
|
+
path.write_text(self.to_json(), encoding="utf-8")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from .manifest import LogicalMovie, Variant
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NamingConvention(Protocol):
|
|
9
|
+
def directory_name(self, movie: LogicalMovie) -> str: ...
|
|
10
|
+
def video_filename(self, movie: LogicalMovie, variant: Variant | None) -> str: ...
|
|
11
|
+
def sidecar_filename(self, movie: LogicalMovie, kind: str) -> str: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PlexNamingConvention:
|
|
15
|
+
"""Plex's documented layout: `<Title> (<Year>) {imdb-tt...}`.
|
|
16
|
+
|
|
17
|
+
The video filename starts with the directory name (both Plex and
|
|
18
|
+
Jellyfin require this). Optional `[bracket]` tags after the base name
|
|
19
|
+
encode variants. No translation of tag *values* happens here — the
|
|
20
|
+
generator emits whatever the variant carries.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
video_extension: str = ".mkv"
|
|
24
|
+
|
|
25
|
+
def directory_name(self, movie: LogicalMovie) -> str:
|
|
26
|
+
name = f"{movie.title} ({movie.year})"
|
|
27
|
+
if movie.imdb_id:
|
|
28
|
+
name += f" {{imdb-{movie.imdb_id}}}"
|
|
29
|
+
return name
|
|
30
|
+
|
|
31
|
+
def video_filename(self, movie: LogicalMovie, variant: Variant | None) -> str:
|
|
32
|
+
base = self.directory_name(movie)
|
|
33
|
+
tags = self._variant_tags(variant)
|
|
34
|
+
if tags:
|
|
35
|
+
base += " " + "".join(f"[{t}]" for t in tags)
|
|
36
|
+
return base + self.video_extension
|
|
37
|
+
|
|
38
|
+
def sidecar_filename(self, movie: LogicalMovie, kind: str) -> str:
|
|
39
|
+
if kind == "nfo":
|
|
40
|
+
return self.directory_name(movie) + ".nfo"
|
|
41
|
+
if kind == "poster":
|
|
42
|
+
return "poster.jpg"
|
|
43
|
+
raise ValueError(f"Unknown sidecar kind: {kind}")
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _variant_tags(variant: Variant | None) -> list[str]:
|
|
47
|
+
if variant is None:
|
|
48
|
+
return []
|
|
49
|
+
tags: list[str] = []
|
|
50
|
+
if variant.resolution:
|
|
51
|
+
tags.append(variant.resolution)
|
|
52
|
+
if variant.edition:
|
|
53
|
+
tags.append(variant.edition)
|
|
54
|
+
tags.extend(variant.extras)
|
|
55
|
+
return tags
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
_MASK_64 = 0xFFFFFFFFFFFFFFFF
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def seed_to_int(seed: str, shift: int = 5) -> int:
|
|
9
|
+
"""Derive a deterministic 64-bit integer from an arbitrary string.
|
|
10
|
+
|
|
11
|
+
Byte-wise rolling XOR-hash. Independent of Python's per-process hash
|
|
12
|
+
randomization (PYTHONHASHSEED), so the same string always yields the
|
|
13
|
+
same int across processes and platforms.
|
|
14
|
+
"""
|
|
15
|
+
h = 0
|
|
16
|
+
for b in seed.encode("utf-8"):
|
|
17
|
+
h = ((h << shift) ^ b) & _MASK_64
|
|
18
|
+
return h
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rng_from_seed(seed: str, *, salt: str = "") -> random.Random:
|
|
22
|
+
"""Return a `random.Random` seeded deterministically from `seed`.
|
|
23
|
+
|
|
24
|
+
`salt` lets callers derive independent RNG streams from the same
|
|
25
|
+
user-facing seed (e.g. one for content, one for structure decisions).
|
|
26
|
+
"""
|
|
27
|
+
return random.Random(seed_to_int(seed + salt))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from .manifest import LogicalMovie
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LibraryStructure(Protocol):
|
|
11
|
+
def parent_path(self, movie: LogicalMovie, rng: random.Random) -> Path: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FlatLibraryStructure:
|
|
15
|
+
"""All movie directories sit directly under the library root."""
|
|
16
|
+
|
|
17
|
+
def parent_path(self, movie: LogicalMovie, rng: random.Random) -> Path:
|
|
18
|
+
return Path(".")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TitleSource(Protocol):
|
|
8
|
+
def random_title(self, rng: random.Random) -> str: ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_ADJECTIVES = (
|
|
12
|
+
"Silent", "Forgotten", "Burning", "Last", "Crimson", "Endless",
|
|
13
|
+
"Hidden", "Distant", "Quiet", "Lonely", "Brave", "Frozen",
|
|
14
|
+
"Broken", "Golden", "Wandering", "Restless", "Vanished", "Hollow",
|
|
15
|
+
"Bitter", "Tender", "Twelve", "Northern", "Ancient", "Sacred",
|
|
16
|
+
"Lost", "Faithful", "Wounded", "Wild", "Pale", "Dark",
|
|
17
|
+
"Bright", "Velvet", "Glass", "Iron", "Stone", "Paper",
|
|
18
|
+
"Quiet", "Salt", "Stormy", "Silver",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_NOUNS = (
|
|
22
|
+
"Shadow", "Empire", "River", "Moon", "Whisper", "Vow",
|
|
23
|
+
"Code", "Echo", "Hunter", "Sentinel", "Tide", "Gravity",
|
|
24
|
+
"Compass", "Lantern", "Mirror", "Falcon", "Verdict", "Ember",
|
|
25
|
+
"Harbor", "Garden", "Tower", "Crown", "Wolf", "Saint",
|
|
26
|
+
"Bridge", "Witness", "Promise", "Letter", "Garden", "Pilot",
|
|
27
|
+
"Stranger", "Hour", "Country", "House", "Lover", "Daughter",
|
|
28
|
+
"Son", "Brother", "Sister", "Captain", "Doctor", "Witness",
|
|
29
|
+
"Forest", "Mountain", "Valley", "Island", "Desert", "Canyon",
|
|
30
|
+
"Train", "Engine", "Machine", "Mirror",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_HAND_CRAFTED = (
|
|
34
|
+
"Twelve Crows in Winter",
|
|
35
|
+
"Notes on a Quiet War",
|
|
36
|
+
"The Lighthouse Keeper",
|
|
37
|
+
"All the Hours Between",
|
|
38
|
+
"A Letter from Nowhere",
|
|
39
|
+
"The Cartographer",
|
|
40
|
+
"Three Days in November",
|
|
41
|
+
"Bread, Salt, and Iron",
|
|
42
|
+
"The Last Honest Man",
|
|
43
|
+
"Songs for the Drowning",
|
|
44
|
+
"The Long Road Home",
|
|
45
|
+
"Where the Wolves Wait",
|
|
46
|
+
"A Brief History of Lying",
|
|
47
|
+
"The Glass Republic",
|
|
48
|
+
"Children of Static",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CuratedTitleSource:
|
|
53
|
+
"""Generates plausible-feeling movie titles from small word pools.
|
|
54
|
+
|
|
55
|
+
Mixes several patterns so a generated library doesn't look monotonous:
|
|
56
|
+
plain nouns, adjective + noun, "The X", "X of Y", and the occasional
|
|
57
|
+
hand-crafted title for texture.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def random_title(self, rng: random.Random) -> str:
|
|
61
|
+
roll = rng.random()
|
|
62
|
+
if roll < 0.10:
|
|
63
|
+
return rng.choice(_HAND_CRAFTED)
|
|
64
|
+
if roll < 0.30:
|
|
65
|
+
return f"The {rng.choice(_NOUNS)}"
|
|
66
|
+
if roll < 0.50:
|
|
67
|
+
return f"The {rng.choice(_ADJECTIVES)} {rng.choice(_NOUNS)}"
|
|
68
|
+
if roll < 0.65:
|
|
69
|
+
return f"{rng.choice(_ADJECTIVES)} {rng.choice(_NOUNS)}"
|
|
70
|
+
if roll < 0.80:
|
|
71
|
+
a, b = rng.sample(_NOUNS, 2)
|
|
72
|
+
return f"{a} of {b}"
|
|
73
|
+
if roll < 0.92:
|
|
74
|
+
a, b = rng.sample(_NOUNS, 2)
|
|
75
|
+
return f"{a} and {b}"
|
|
76
|
+
return rng.choice(_NOUNS)
|