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.
@@ -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)