Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping
This commit is contained in:
+186
-6
@@ -21,7 +21,125 @@ Each version entry uses these sections (include only those that apply):
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
- **The pool index is keyed by the movie, not by a file path** (index **v2**,
|
||||
`src/core/movie.py`). A record used to describe one *file*, so a movie's
|
||||
metadata was a passenger on one particular file — which is why the "primary
|
||||
version" had to carry the tags, why removing it needed a metadata-rescue
|
||||
hack, and why renaming a multi-version movie had to be disabled outright.
|
||||
Now the record **is** the movie (`tmdb-603`, or a diacritics-folded
|
||||
`name-<title>-<year>` slug without an id) and owns its files:
|
||||
```json
|
||||
"tmdb-19995": {"title": "Avatar", "year": "2009", "tmdb_id": 19995,
|
||||
"library": "Filmy", "tags": [...], "csfd_url": …,
|
||||
"media": [{"role": "video", "label": "", "file": "…"},
|
||||
{"role": "video", "label": "3D HSBS", "file": "…"}]}
|
||||
```
|
||||
Every version of a film therefore reads and writes **the same** metadata —
|
||||
adding a version inherits it, removing any version (including the one shown
|
||||
in the table) leaves it intact, and the rescue hack (plus the interim
|
||||
`File.is_primary_version` flag) is gone. A newly seen file also joins an
|
||||
existing record of the same identity instead of blanking its metadata.
|
||||
`Movie.desired_layout` derives where the files belong from the record alone
|
||||
(one video → flat in the library, two or more → its own
|
||||
`Title (rok) [tmdbid]/` folder; a 3D label keeps Jellyfin's documented
|
||||
`.3D.<FORMAT>` spelling in both layouts, other labels use `" - label"`) —
|
||||
**not wired in yet**: `add_movie_version` / `remove_movie_version` still lay
|
||||
files out themselves, and the edit dialog doesn't apply a recomputed layout
|
||||
on rename. A move now updates the record's `library` as well.
|
||||
`check_data_consistency` compares disk against the records' media paths.
|
||||
`File` is now a *view* on one media entry of a record (same public
|
||||
interface); `PoolIndex` gained `get_movie`/`set_movie`/`delete_movie`/
|
||||
`rekey_movie`/`all_movies`/`find_movie_by_media`/`record_for`, and
|
||||
**migrates a v1 index on first open** — metadata only, **no file is moved**
|
||||
(a v1 movie already sits where v2 wants it), with the old index copied to
|
||||
`.Curator.!index.v1.bak` first.
|
||||
|
||||
### Added
|
||||
- **Multiple versions of one movie** (Jellyfin's own "multiple versions" —
|
||||
2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its
|
||||
`(title, year, tmdbid)` and stays a **flat file** while it has exactly one
|
||||
playable version (today's layout, unchanged). Adding a second version
|
||||
**promotes it automatically** into its own folder — Jellyfin's required
|
||||
shape, every file prefixed with the exact folder name:
|
||||
```
|
||||
Title (rok) [tmdbid]/
|
||||
Title (rok) [tmdbid] - Výchozí.ext
|
||||
Title (rok) [tmdbid] - 3D HSBS.ext
|
||||
```
|
||||
*Upravit film* gets a **„Verze"** list — **"➕ Přidat verzi…"** (pick a file,
|
||||
then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes
|
||||
exactly `3D <FORMÁT>`, the same tokens Jellyfin scans for in a flat
|
||||
`.3D.<FORMÁT>` filename — or free text for a non-3D version like `4K` /
|
||||
`Director's Cut`) and **"➖ Odebrat verzi"** (removing down to one version
|
||||
**demotes back to flat** automatically). The table still shows **one row
|
||||
per movie** — a group's other versions live only in that list, not as
|
||||
separate rows (`FileManager.visible_movies`; the row shown is the plain /
|
||||
`Výchozí` version, `primary_version`). All versions share **one metadata
|
||||
record** (see *Changed → index v2*), so a new version inherits the title/
|
||||
tags/ČSFD/atributy and removing any version — even the one shown in the table
|
||||
— leaves them intact. Renaming/moving/retitling a grouped movie isn't
|
||||
supported yet (those fields are disabled in the edit dialog; remove the
|
||||
version or edit the files directly). `FileManager.is_movie_group_member` /
|
||||
`list_movie_versions` / `primary_version` / `add_movie_version` /
|
||||
`remove_movie_version`. Scoped to `movie`-kind libraries for now —
|
||||
`mixed`-library grouped movies are a follow-up (their subfolders are still
|
||||
read as series only).
|
||||
- **3D movie tagging** (`naming.py`, Jellyfin's own convention —
|
||||
https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets
|
||||
a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC),
|
||||
pre-filled from the current filename; picking one renames the pool file to
|
||||
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`. The tag is preserved across every
|
||||
future rename (editing other fields, "Přejmenovat dle metadat") the same way
|
||||
`[tmdbid-N]` already is — `canonical_pool_stem` / `parse_3d_format` /
|
||||
`THREE_D_FORMATS`; `apply_movie_edits(format_3d=…)` (omitted = keep, `""` =
|
||||
clear). This is for a movie that's **only ever going to have one file** —
|
||||
deliberately **not** exposed in the import dialog: importing a second file
|
||||
for a movie already in the pool that way would create a plain, ungrouped
|
||||
duplicate instead of a proper version (see "Multiple movie versions" above,
|
||||
which is the right tool for 2D+3D of the same film). `import_movie` still
|
||||
takes `format_3d=…` as a building block, just with no UI wired to it.
|
||||
- **miniDLNA hardcopies `homevideo` libraries too.** A `homevideo` library
|
||||
(no TMDb id, arbitrary internal structure) has no genre tags to build a tree
|
||||
from — same reasoning as series — so it's now mirrored whole into
|
||||
`output/<název knihovny>` alongside the series copy-as-is mirrors
|
||||
(`FileManager.mirror_as_is_libraries` = `series` + `homevideo`; the
|
||||
series-only `copyasis_folders` used by the library organizer is unchanged).
|
||||
Respects the per-library miniDLNA selection.
|
||||
- **miniDLNA covers series inside `mixed` libraries.** A `mixed` library's
|
||||
top-level files are movies (genre-tagged as usual); any **series in its
|
||||
subfolders** now get hardcopied into `output/Seriály/<vlastní název>/…` —
|
||||
no genre tags apply to a series, so it bypasses the tag tree entirely, same
|
||||
mechanism as a dedicated series library's copy-as-is mirror but scoped to one
|
||||
series' own folder. Respects the per-library miniDLNA selection below.
|
||||
`FileManager.minidlna_scope` now returns a third element, the in-scope
|
||||
mixed-library `series.Series` list.
|
||||
- **miniDLNA: choose which libraries generate.** *miniDLNA → "Knihovny pro
|
||||
generování…"* — a checklist of the pool's libraries (persisted in the pool
|
||||
index, default = all, matching the previous behaviour). "Vygenerovat miniDLNA
|
||||
strom" then only includes movies from the checked libraries in the genre tree
|
||||
/ Tipy dne / Nově přidané, and only mirrors checked series/homevideo
|
||||
libraries — the dialog warns up front that unchecking one removes its output.
|
||||
A movie library's genre-tree links are swept as obsolete the normal way; a
|
||||
mirrored (series/homevideo) library or a mixed-library series has **no**
|
||||
obsolete-link mechanism of its own, so deselecting one is handled explicitly:
|
||||
`FileManager.minidlna_prune_targets` works out what fell out of scope (the
|
||||
whole-library output folder, or — since a mixed-library series and a
|
||||
same-named real library can share one output bucket like `Seriály` — just the
|
||||
stale entries in it) and `HardlinkManager.remove_mirror` deletes it.
|
||||
`FileManager.minidlna_libraries` / `set_minidlna_libraries` / `minidlna_scope`.
|
||||
- **External subtitle management for movies** (`src/core/subtitles.py`). Jellyfin
|
||||
plays an external `.srt`/`.ass`/`.ssa`/`.sub`/`.vtt` natively when it shares the
|
||||
video's filename stem (`Title (rok) [tmdbid].cs.srt`, optionally `.forced`/
|
||||
`.sdh`/`.default`) — no muxing needed. *Upravit film* lists a movie's attached
|
||||
subtitles and can **"➕ Přidat titulky…"** (pick a file, tag its language,
|
||||
mark forced; copied in and named to match) or remove one. Subtitles now
|
||||
**follow the movie through every rename/move** (`rename_movie`,
|
||||
`apply_movie_edits`, `move_to_library`, `rename_to_canonical`, and
|
||||
`rename_episodes_to_canonical` for series) instead of silently detaching —
|
||||
`subtitles.rename_subtitles_with`. `FileManager.list_subtitles` /
|
||||
`attach_subtitle` / `remove_subtitle`. Presence is read live from disk (like
|
||||
NFO), nothing is cached in the pool index.
|
||||
- **Movies and series in one list.** The main table lists both as rows with a
|
||||
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
|
||||
the episodes for a series. Double-click (or the context menu) opens the right
|
||||
@@ -52,7 +170,67 @@ Each version entry uses these sections (include only those that apply):
|
||||
imported into a mixed library and show up in the series overview/editor.
|
||||
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
|
||||
import (a column in the movie import dialog) and editable in *Upravit film*.
|
||||
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
|
||||
- **ČSFD → TMDb title bridge** (`src/core/csfd.py`, lean revival of the removed
|
||||
scraper). A direct TMDb search on a Czech distribution title is unreliable
|
||||
(remakes, localized names); ČSFD indexes Czech release names well **and** lists
|
||||
each film's *original* title, so the flow is: filename → ČSFD search → ČSFD
|
||||
film page → original title + year → TMDb search → tmdbid.
|
||||
- Import dialog: a second button **"🎬 Dohledat přes ČSFD"** next to "Dohledat
|
||||
z TMDb". Per row it uses the filled ČSFD odkaz, else searches ČSFD by the
|
||||
cleaned filename; fills ČSFD odkaz / TMDb ID / Název / Rok (blue = suggested).
|
||||
- Migration: a movie carrying a `csfd_url` is resolved through ČSFD first, so
|
||||
`match_pool_to_tmdb` searches on the original title + year (the proposal is
|
||||
tinted blue with a *"přes ČSFD: …"* tooltip). `FileManager.resolve_via_csfd`.
|
||||
- Search is scoped to the *Filmy* section and rejects two-segment episode
|
||||
hrefs (a bare-title search otherwise returned a series episode); a yearful
|
||||
query that misses is retried without the year. The original title is only
|
||||
taken from a `.film-names` entry whose flag matches the country of origin —
|
||||
a Czech film (which lists only foreign alt-titles there) keeps its `<h1>`
|
||||
title instead of a random Slovenian/English one.
|
||||
- Scope: a title→tmdbid bridge only — **no ČSFD tags/genres return** (those
|
||||
stay TMDb-via-Jellyfin/NFO). ČSFD's Anubis proof-of-work wall is solved
|
||||
(SHA-256, stdlib) and replayed over a shared session so a batch pays it once.
|
||||
- **Import rows pre-filled from metadata next to the source file.** When a picked
|
||||
video sits next to metadata from an earlier Curator/Tagger setup, the import
|
||||
row is seeded on open (no network) — **ČSFD odkaz**, Název, Rok (blue =
|
||||
suggested, verify):
|
||||
- `src/core/legacy_meta.py` — reads Curator's **pre-index** layouts: a per-file
|
||||
`.{filename}.!tag` JSON sidecar (Tagger / early Curator; key `csfd_link`),
|
||||
else a `.Curator.!index` found walking up the tree (matched by relative key,
|
||||
then by unique basename). These win for the ČSFD link.
|
||||
- `nfo.read_nfo` also parses a ČSFD id/URL from `<uniqueid type="csfd">` (bare
|
||||
id or full URL) / legacy `<csfdid>` / `<csfd>` (`nfo.NfoData.csfd_url`), and
|
||||
fills what the sidecar/index don't carry (notably the TMDb id).
|
||||
- `ImportMoviesDialog._prefill_row`.
|
||||
- **Import: "Vynechat řádky bez TMDb ID".** A checkbox in the import dialog that
|
||||
drops every row without a TMDb ID on OK — they are skipped (and no longer need
|
||||
a year). Rows that would be skipped are shown struck-through/italic as the box
|
||||
is ticked; the import summary reports how many were skipped
|
||||
(`ImportMoviesDialog.skipped_no_tmdb`).
|
||||
- **Episode import: "📁 Přidat složku…".** The episode-import dialog can now take
|
||||
a whole series folder and scans it **recursively** (all subfolders — `Season
|
||||
01`, `Season 02`, …), adding every video file it finds (sidecars skipped);
|
||||
season/episode still parsed per file. A canonical folder name
|
||||
(`Title (rok) [tmdbid-N]`) also pre-fills the series title / year / TMDb ID.
|
||||
The dialog now opens empty (add files or a folder from inside it).
|
||||
`series.collect_episode_files`, `ImportEpisodesDialog._add_folder`.
|
||||
- **Episode import: editable SxxEyy per row.** The `SxxEyy` column is now an
|
||||
editable field, pre-filled from the filename and colour-coded (green valid /
|
||||
red unparsable / plain empty). Fill in an unrecognised episode (or fix a
|
||||
mis-parsed one) right in the import dialog — `S01E05` / `1x05` both work; OK
|
||||
warns before importing if any row still has no valid code.
|
||||
`ImportEpisodesDialog.episode_codes` feeds a new `codes=` override on
|
||||
`FileManager.import_episodes`.
|
||||
- **Remove a series from the pool.** *Seriály → "Odebrat z poolu…"*, the table
|
||||
context menu (series-only selection, one or more), and a button in *Přehled
|
||||
seriálů* delete a whole series folder — every season and episode — after a
|
||||
confirm. `FileManager.remove_series` (refuses a folder outside the pool).
|
||||
- **Bulk-fix series episode filenames.** *Seriály → "Napravit názvy souborů…"*
|
||||
goes over every series in the pool and renames each episode to
|
||||
**`SxxEyy - {název seriálu}.ext`** in the right `Season NN` / `Specials`
|
||||
folder, then removes the season folders emptied by the moves. Episodes with no
|
||||
recognisable `SxxEyy` are left in place and listed (fix them in the series
|
||||
editor). `FileManager.rename_all_series_to_canonical`.
|
||||
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
|
||||
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
|
||||
title + year and proposes an id, filling a missing year from TMDb. Conservative
|
||||
@@ -71,11 +249,13 @@ Each version entry uses these sections (include only those that apply):
|
||||
Jellyfin to write NFO before the genre tree is populated.
|
||||
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
|
||||
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
|
||||
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
|
||||
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
|
||||
`SxxEyy` code is stripped from the original before re-applying, so repeated
|
||||
renames don't double it (`series.episode_body`); `episode_filename` /
|
||||
`is_canonical_episode` and the series import/rename/edit paths follow.
|
||||
- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
|
||||
Season NN/`. The release string is deliberately dropped — identity is the
|
||||
`SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
|
||||
(matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
|
||||
collapses to `SxxEyy.ext`. `episode_filename` takes the series title,
|
||||
`is_canonical_episode(path, series_title)`, and import / rename / edit all
|
||||
follow (`series.sanitize_title`).
|
||||
|
||||
### Fixed
|
||||
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
|
||||
|
||||
Reference in New Issue
Block a user