riplex 0.6.3__tar.gz → 0.6.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {riplex-0.6.3 → riplex-0.6.4}/.github/workflows/release.yml +5 -0
  2. {riplex-0.6.3 → riplex-0.6.4}/PKG-INFO +1 -1
  3. {riplex-0.6.3 → riplex-0.6.4}/docs/changelog.md +5 -0
  4. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/disc/provider.py +91 -0
  5. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/PKG-INFO +1 -1
  6. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/release.py +157 -12
  7. {riplex-0.6.3 → riplex-0.6.4}/tests/test_disc_provider.py +43 -0
  8. {riplex-0.6.3 → riplex-0.6.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  9. {riplex-0.6.3 → riplex-0.6.4}/.github/ISSUE_TEMPLATE/crash_report.yml +0 -0
  10. {riplex-0.6.3 → riplex-0.6.4}/.github/agents/riplex.agent.md +0 -0
  11. {riplex-0.6.3 → riplex-0.6.4}/.github/copilot-instructions.md +0 -0
  12. {riplex-0.6.3 → riplex-0.6.4}/.github/workflows/publish.yml +0 -0
  13. {riplex-0.6.3 → riplex-0.6.4}/.gitignore +0 -0
  14. {riplex-0.6.3 → riplex-0.6.4}/.vscode/settings.json +0 -0
  15. {riplex-0.6.3 → riplex-0.6.4}/CONTRIBUTORS.md +0 -0
  16. {riplex-0.6.3 → riplex-0.6.4}/LICENSE +0 -0
  17. {riplex-0.6.3 → riplex-0.6.4}/README.md +0 -0
  18. {riplex-0.6.3 → riplex-0.6.4}/docs/architecture.md +0 -0
  19. {riplex-0.6.3 → riplex-0.6.4}/docs/getting-started/configuration.md +0 -0
  20. {riplex-0.6.3 → riplex-0.6.4}/docs/getting-started/installation.md +0 -0
  21. {riplex-0.6.3 → riplex-0.6.4}/docs/guide/lookup.md +0 -0
  22. {riplex-0.6.3 → riplex-0.6.4}/docs/guide/orchestrate.md +0 -0
  23. {riplex-0.6.3 → riplex-0.6.4}/docs/guide/organize.md +0 -0
  24. {riplex-0.6.3 → riplex-0.6.4}/docs/guide/workflow.md +0 -0
  25. {riplex-0.6.3 → riplex-0.6.4}/docs/index.md +0 -0
  26. {riplex-0.6.3 → riplex-0.6.4}/docs/naming-rules.md +0 -0
  27. {riplex-0.6.3 → riplex-0.6.4}/docs/reference/cli.md +0 -0
  28. {riplex-0.6.3 → riplex-0.6.4}/docs/troubleshooting.md +0 -0
  29. {riplex-0.6.3 → riplex-0.6.4}/issues/debug-artifacts-consolidation.md +0 -0
  30. {riplex-0.6.3 → riplex-0.6.4}/issues/orchestrate-dvdcompare-fallback.md +0 -0
  31. {riplex-0.6.3 → riplex-0.6.4}/issues/planned-features.md +0 -0
  32. {riplex-0.6.3 → riplex-0.6.4}/mkdocs.yml +0 -0
  33. {riplex-0.6.3 → riplex-0.6.4}/pyproject.toml +0 -0
  34. {riplex-0.6.3 → riplex-0.6.4}/setup.cfg +0 -0
  35. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/__init__.py +0 -0
  36. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/cache.py +0 -0
  37. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/config.py +0 -0
  38. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/dedup.py +0 -0
  39. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/detect.py +0 -0
  40. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/disc/__init__.py +0 -0
  41. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/disc/analysis.py +0 -0
  42. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/disc/makemkv.py +0 -0
  43. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/formatter.py +0 -0
  44. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/lookup.py +0 -0
  45. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/manifest.py +0 -0
  46. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/matcher.py +0 -0
  47. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/metadata/__init__.py +0 -0
  48. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/metadata/planner.py +0 -0
  49. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/metadata/provider.py +0 -0
  50. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/metadata/sources/__init__.py +0 -0
  51. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/metadata/sources/tmdb.py +0 -0
  52. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/models.py +0 -0
  53. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/normalize.py +0 -0
  54. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/organizer.py +0 -0
  55. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/scanner.py +0 -0
  56. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/snapshot.py +0 -0
  57. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/splitter.py +0 -0
  58. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/tagger.py +0 -0
  59. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/title.py +0 -0
  60. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/ui.py +0 -0
  61. {riplex-0.6.3 → riplex-0.6.4}/src/riplex/updater.py +0 -0
  62. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/SOURCES.txt +0 -0
  63. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/dependency_links.txt +0 -0
  64. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/entry_points.txt +0 -0
  65. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/requires.txt +0 -0
  66. {riplex-0.6.3 → riplex-0.6.4}/src/riplex.egg-info/top_level.txt +0 -0
  67. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/__init__.py +0 -0
  68. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/bug_report.py +0 -0
  69. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/crash_dump.py +0 -0
  70. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/keep_awake.py +0 -0
  71. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/main.py +0 -0
  72. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/__init__.py +0 -0
  73. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/disc_detection.py +0 -0
  74. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/disc_overview.py +0 -0
  75. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/disc_swap.py +0 -0
  76. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/done.py +0 -0
  77. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/folder_picker.py +0 -0
  78. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/metadata.py +0 -0
  79. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/orchestrate_done.py +0 -0
  80. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/organize_done.py +0 -0
  81. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/organize_preview.py +0 -0
  82. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/progress.py +0 -0
  83. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/selection.py +0 -0
  84. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/update.py +0 -0
  85. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_app/screens/welcome.py +0 -0
  86. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/__init__.py +0 -0
  87. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/__init__.py +0 -0
  88. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/lookup.py +0 -0
  89. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/orchestrate.py +0 -0
  90. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/organize.py +0 -0
  91. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/rip.py +0 -0
  92. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/commands/setup.py +0 -0
  93. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/formatting.py +0 -0
  94. {riplex-0.6.3 → riplex-0.6.4}/src/riplex_cli/main.py +0 -0
  95. {riplex-0.6.3 → riplex-0.6.4}/tests/__init__.py +0 -0
  96. {riplex-0.6.3 → riplex-0.6.4}/tests/fixtures/chernobyl_disc1.json +0 -0
  97. {riplex-0.6.3 → riplex-0.6.4}/tests/fixtures/makemkvcon_frozen_planet_ii_d2.txt +0 -0
  98. {riplex-0.6.3 → riplex-0.6.4}/tests/fixtures/makemkvcon_list.txt +0 -0
  99. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/Batman Begins.snapshot.json +0 -0
  100. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/Blade Runner (Blu-ray 4k).snapshot.json +0 -0
  101. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/Blade Runner The Final Cut.snapshot.json +0 -0
  102. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/Seven Worlds One Planet.snapshot.json +0 -0
  103. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/The Dark Knight Rises.snapshot.json +0 -0
  104. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/The Dark Knight.snapshot.json +0 -0
  105. {riplex-0.6.3 → riplex-0.6.4}/tests/snapshots/Waterworld.snapshot.json +0 -0
  106. {riplex-0.6.3 → riplex-0.6.4}/tests/test_cache.py +0 -0
  107. {riplex-0.6.3 → riplex-0.6.4}/tests/test_cli_utils.py +0 -0
  108. {riplex-0.6.3 → riplex-0.6.4}/tests/test_config.py +0 -0
  109. {riplex-0.6.3 → riplex-0.6.4}/tests/test_dedup.py +0 -0
  110. {riplex-0.6.3 → riplex-0.6.4}/tests/test_detect.py +0 -0
  111. {riplex-0.6.3 → riplex-0.6.4}/tests/test_disc_analysis.py +0 -0
  112. {riplex-0.6.3 → riplex-0.6.4}/tests/test_disc_detection_screen.py +0 -0
  113. {riplex-0.6.3 → riplex-0.6.4}/tests/test_disc_fixtures.py +0 -0
  114. {riplex-0.6.3 → riplex-0.6.4}/tests/test_formatter.py +0 -0
  115. {riplex-0.6.3 → riplex-0.6.4}/tests/test_makemkv.py +0 -0
  116. {riplex-0.6.3 → riplex-0.6.4}/tests/test_matcher.py +0 -0
  117. {riplex-0.6.3 → riplex-0.6.4}/tests/test_normalize.py +0 -0
  118. {riplex-0.6.3 → riplex-0.6.4}/tests/test_organizer.py +0 -0
  119. {riplex-0.6.3 → riplex-0.6.4}/tests/test_planner.py +0 -0
  120. {riplex-0.6.3 → riplex-0.6.4}/tests/test_rip_guide.py +0 -0
  121. {riplex-0.6.3 → riplex-0.6.4}/tests/test_scanner.py +0 -0
  122. {riplex-0.6.3 → riplex-0.6.4}/tests/test_snapshot.py +0 -0
  123. {riplex-0.6.3 → riplex-0.6.4}/tests/test_splitter.py +0 -0
  124. {riplex-0.6.3 → riplex-0.6.4}/tests/test_tagger.py +0 -0
  125. {riplex-0.6.3 → riplex-0.6.4}/tests/test_ui.py +0 -0
  126. {riplex-0.6.3 → riplex-0.6.4}/tests/test_updater.py +0 -0
@@ -164,6 +164,11 @@ jobs:
164
164
  - name: Create GitHub Release
165
165
  uses: softprops/action-gh-release@v2
166
166
  with:
167
+ # Auto-generate notes from commits and PRs since the previous tag,
168
+ # then prepend the tag annotation (if any) as a header. Using just
169
+ # the tag annotation produces single-line releases when the tag
170
+ # was created with `git tag -a -m "<subject>"` and no body.
171
+ generate_release_notes: true
167
172
  body: ${{ steps.tag_notes.outputs.body }}
168
173
  files: |
169
174
  release/riplex-windows.exe
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
10
10
 
11
11
  - Bumped `dvdcompare-scraper` pin to `>=0.1.15`, which adds quoted-title disc-header parsing. Boxsets such as *Back to the Future 40th Anniversary Trilogy* that glue six physical discs into a single dvdcompare disc entry are now split correctly into one PlannedDisc per physical disc.
12
12
 
13
+ ### Added
14
+
15
+ - Disc Release screen: "View on dvdcompare.net" link showing the auto-selected film page so users can verify region/edition before committing to a long rip.
16
+ - Disc Release screen: manual film-id override input. Paste either a bare fid (e.g. `55540`) or a full URL (`https://www.dvdcompare.net/comparisons/film.php?fid=55540`) and riplex will fetch and use that film page instead. The chosen fid is persisted per `(title, disc_format)` so swapping discs in the same box set keeps the override, with a "Clear saved override" affordance.
17
+
13
18
  ## 2026-05-12
14
19
 
15
20
  ### Added
@@ -208,6 +208,82 @@ class DiscProvider:
208
208
  # in screens / external code may still use this; prefer ``fetch_film``.
209
209
  _fetch_film_cached = fetch_film
210
210
 
211
+ async def fetch_film_by_id(self, film_id: int) -> FilmComparison:
212
+ """Fetch a specific dvdcompare film page by its film id.
213
+
214
+ Used by the UI's manual-override path when our auto-ranking picks
215
+ the wrong film page. Cached on success; HTTP/network failures
216
+ propagate as LookupError after caching a short-TTL negative entry.
217
+ """
218
+ cache_key = cache.hash_key(f"film-by-id|{film_id}")
219
+
220
+ cached = cache.cache_get(self.cache_ns, cache_key, ttl_days=self.ttl_days)
221
+ if cached is not None and not cached.get("_negative"):
222
+ log.debug("dvdcompare film-by-id cache hit for %s", film_id)
223
+ return _dict_to_film(cached)
224
+
225
+ url = film_url(film_id)
226
+ try:
227
+ film = await _throttled_get_film_by_url(url)
228
+ except httpx.HTTPStatusError as exc:
229
+ status = exc.response.status_code if exc.response is not None else "?"
230
+ log.warning("dvdcompare HTTP %s fetching fid=%s", status, film_id)
231
+ raise LookupError(
232
+ f"dvdcompare returned HTTP {status} for fid={film_id}"
233
+ ) from exc
234
+ except httpx.RequestError as exc:
235
+ log.warning("dvdcompare network error fetching fid=%s: %s", film_id, exc)
236
+ raise LookupError(
237
+ f"dvdcompare network error for fid={film_id}: {exc}"
238
+ ) from exc
239
+
240
+ if not film.releases:
241
+ raise LookupError(f"dvdcompare film {film_id} has no releases")
242
+
243
+ cache.cache_set(self.cache_ns, cache_key, dataclasses.asdict(film))
244
+ return film
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Public URL / fid helpers
249
+ # ---------------------------------------------------------------------------
250
+
251
+ from dvdcompare.scraper import BASE_URL as _DVDCOMPARE_BASE_URL # noqa: E402
252
+
253
+
254
+ def film_url(film_id: int | str) -> str:
255
+ """Return the public dvdcompare comparison URL for a film id."""
256
+ return f"{_DVDCOMPARE_BASE_URL}/comparisons/film.php?fid={film_id}"
257
+
258
+
259
+ _FID_RE = re.compile(r"(?:fid=|film\.php\?fid=|^)(\d+)")
260
+
261
+
262
+ def parse_film_id(value: str) -> int | None:
263
+ """Extract a numeric film id from a bare number or a dvdcompare URL.
264
+
265
+ Returns ``None`` if no fid can be parsed.
266
+
267
+ Accepted forms:
268
+ - ``"55540"``
269
+ - ``"fid=55540"``
270
+ - ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540"``
271
+ - ``"https://www.dvdcompare.net/comparisons/film.php?fid=55540#2"``
272
+ """
273
+ if not value:
274
+ return None
275
+ s = value.strip()
276
+ if not s:
277
+ return None
278
+ # Pure digits
279
+ if s.isdigit():
280
+ return int(s)
281
+ m = _FID_RE.search(s)
282
+ if m:
283
+ return int(m.group(1))
284
+ return None
285
+
286
+
211
287
 
212
288
  # ---------------------------------------------------------------------------
213
289
  # Internal helpers: negative cache + throttle
@@ -284,6 +360,21 @@ async def _throttled_find_film(
284
360
  _last_request_at = time.monotonic()
285
361
 
286
362
 
363
+ async def _throttled_get_film_by_url(url: str) -> FilmComparison:
364
+ """Call ``get_film_by_url`` under the same throttle as ``find_film``."""
365
+ global _last_request_at
366
+ async with _request_lock:
367
+ now = time.monotonic()
368
+ wait = _MIN_INTERVAL_S - (now - _last_request_at)
369
+ if wait > 0:
370
+ log.debug("dvdcompare throttle: waiting %.2fs before request", wait)
371
+ await asyncio.sleep(wait)
372
+ try:
373
+ return await get_film_by_url(url)
374
+ finally:
375
+ _last_request_at = time.monotonic()
376
+
377
+
287
378
  async def _find_film_prefer_format(
288
379
  title: str, disc_format: str, year: int | None
289
380
  ) -> FilmComparison:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riplex
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: Automates the tedious manual work around MakeMKV: figuring out what to rip, which MKV files are actually what, and organizing everything into Plex-compatible folder structures.
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -2,11 +2,16 @@
2
2
 
3
3
  import asyncio
4
4
  import threading
5
+ import webbrowser
5
6
 
6
7
  import flet as ft
7
8
 
9
+ from riplex import cache as _cache
8
10
  from riplex.disc.provider import DiscProvider, _convert_release
9
- from riplex.disc.provider import detect_disc_format, score_releases
11
+ from riplex.disc.provider import detect_disc_format, film_url, parse_film_id, score_releases
12
+
13
+
14
+ _OVERRIDE_CACHE_NS = "dvdcompare_film_id_override"
10
15
 
11
16
 
12
17
  class ReleaseScreen:
@@ -14,6 +19,8 @@ class ReleaseScreen:
14
19
  self.app = app
15
20
  self.film_comparison = None
16
21
  self._search_field: ft.TextField | None = None
22
+ self._fid_field: ft.TextField | None = None
23
+ self._fid_error: ft.Text | None = None
17
24
 
18
25
  @property
19
26
  def _next_screen(self) -> str:
@@ -73,6 +80,114 @@ class ReleaseScreen:
73
80
  self.app.state.pop("_dvdcompare_error", None)
74
81
  self.app.navigate("release")
75
82
 
83
+ # -- film URL / manual fid override UI --------------------------------
84
+
85
+ def _build_film_link(self, film) -> ft.Control | None:
86
+ """Build a "View on dvdcompare.net" button for the current film."""
87
+ if film is None or not getattr(film, "film_id", None):
88
+ return None
89
+ url = film_url(film.film_id)
90
+
91
+ def _open(_e, _url=url):
92
+ webbrowser.open(_url)
93
+
94
+ return ft.Row(
95
+ [
96
+ ft.Icon(ft.Icons.OPEN_IN_NEW, size=16, color=ft.Colors.BLUE),
97
+ ft.TextButton(
98
+ f"View on dvdcompare.net (fid={film.film_id})",
99
+ on_click=_open,
100
+ tooltip=url,
101
+ ),
102
+ ],
103
+ spacing=4,
104
+ )
105
+
106
+ def _build_fid_override_section(self) -> ft.Control:
107
+ """Editable manual film-id / URL override input."""
108
+ self._fid_field = ft.TextField(
109
+ label="Manual override (paste dvdcompare URL or fid)",
110
+ hint_text="e.g. 55540 or https://www.dvdcompare.net/comparisons/film.php?fid=55540",
111
+ expand=True,
112
+ on_submit=self._on_fid_override_submit,
113
+ )
114
+ self._fid_error = ft.Text("", size=12, color=ft.Colors.RED, visible=False)
115
+ persisted = self._load_persisted_override()
116
+ hint_controls = []
117
+ if persisted is not None:
118
+ hint_controls.append(
119
+ ft.Row([
120
+ ft.Text(
121
+ f"Currently using saved override fid={persisted}.",
122
+ size=12,
123
+ color=ft.Colors.GREY_500,
124
+ ),
125
+ ft.TextButton(
126
+ "Clear saved override",
127
+ on_click=self._on_clear_override,
128
+ ),
129
+ ], spacing=10),
130
+ )
131
+ return ft.Container(
132
+ content=ft.Column(
133
+ [
134
+ ft.Text(
135
+ "Wrong film? Open dvdcompare.net, find the right page, "
136
+ "and paste its URL or fid here:",
137
+ size=12,
138
+ color=ft.Colors.GREY_500,
139
+ ),
140
+ ft.Row(
141
+ [
142
+ self._fid_field,
143
+ ft.ElevatedButton(
144
+ "Use this",
145
+ icon=ft.Icons.CHECK,
146
+ on_click=self._on_fid_override_submit,
147
+ ),
148
+ ],
149
+ spacing=10,
150
+ ),
151
+ self._fid_error,
152
+ *hint_controls,
153
+ ],
154
+ spacing=6,
155
+ ),
156
+ padding=ft.Padding(left=0, top=8, right=0, bottom=8),
157
+ )
158
+
159
+ def _on_fid_override_submit(self, _e):
160
+ if self._fid_field is None:
161
+ return
162
+ raw = (self._fid_field.value or "").strip()
163
+ fid = parse_film_id(raw)
164
+ if fid is None:
165
+ if self._fid_error is not None:
166
+ self._fid_error.value = (
167
+ "Couldn't parse a film id from that input. "
168
+ "Expected a number like 55540 or a "
169
+ "dvdcompare.net/comparisons/film.php?fid=... URL."
170
+ )
171
+ self._fid_error.visible = True
172
+ self.app.page.update()
173
+ return
174
+ # Stash for the next lookup pass and trigger a refresh.
175
+ self.app.state["_dvdcompare_film_id_override"] = fid
176
+ self.app.state["release"] = None
177
+ self.app.state["dvdcompare_discs"] = []
178
+ self.app.state.pop("_dvdcompare_film", None)
179
+ self.app.state.pop("_dvdcompare_error", None)
180
+ self.app.navigate("release")
181
+
182
+ def _on_clear_override(self, _e):
183
+ self._clear_persisted_override()
184
+ self.app.state["release"] = None
185
+ self.app.state["dvdcompare_discs"] = []
186
+ self.app.state.pop("_dvdcompare_film", None)
187
+ self.app.state.pop("_dvdcompare_error", None)
188
+ self.app.navigate("release")
189
+
190
+
76
191
  def build(self) -> ft.Control:
77
192
  title = self._current_search_title()
78
193
 
@@ -166,18 +281,27 @@ class ReleaseScreen:
166
281
  value=default_value,
167
282
  )
168
283
 
284
+ film_link = self._build_film_link(self.film_comparison)
285
+ header_children = [
286
+ ft.Text("Disc Release", size=24, weight=ft.FontWeight.BOLD),
287
+ ft.Text(
288
+ "Multiple releases were found on dvdcompare.net. Pick the one "
289
+ "that matches your physical disc (region, edition, distributor).",
290
+ size=13,
291
+ color=ft.Colors.GREY_500,
292
+ ),
293
+ ]
294
+ if film_link is not None:
295
+ header_children.append(film_link)
296
+ header_children.append(ft.Divider(height=20))
297
+
169
298
  return ft.Column(
170
299
  [
171
- ft.Text("Disc Release", size=24, weight=ft.FontWeight.BOLD),
172
- ft.Text(
173
- "Multiple releases were found on dvdcompare.net. Pick the one "
174
- "that matches your physical disc (region, edition, distributor).",
175
- size=13,
176
- color=ft.Colors.GREY_500,
177
- ),
178
- ft.Divider(height=20),
300
+ *header_children,
179
301
  ft.Text("Select your disc release:", size=14),
180
302
  self.release_radio_group,
303
+ ft.Divider(height=20),
304
+ self._build_fid_override_section(),
181
305
  ft.Container(expand=True),
182
306
  ft.Row([
183
307
  ft.TextButton("Back", on_click=lambda _: self.app.navigate("metadata")),
@@ -212,6 +336,8 @@ class ReleaseScreen:
212
336
  ft.Divider(height=20),
213
337
  self._build_search_bar(title, searching=False),
214
338
  ft.Text(msg, size=14, color=ft.Colors.ORANGE),
339
+ ft.Divider(height=20),
340
+ self._build_fid_override_section(),
215
341
  ft.Container(expand=True),
216
342
  ft.Row([
217
343
  ft.TextButton("Back", on_click=lambda _: self.app.navigate("metadata")),
@@ -238,11 +364,30 @@ class ReleaseScreen:
238
364
  try:
239
365
  disc_format = self._detect_disc_format()
240
366
  year = tmdb_match.year if tmdb_match else None
241
- log.info("dvdcompare lookup: title=%r format=%r year=%r", title, disc_format, year)
242
367
  provider = DiscProvider()
243
- film = asyncio.run(provider.fetch_film(title, disc_format, year=year))
244
- log.info("dvdcompare lookup: found %r (%d releases)",
368
+
369
+ # Per-session manual override (just submitted).
370
+ session_fid = self.app.state.pop("_dvdcompare_film_id_override", None)
371
+ # Persisted override (set on a previous run for this title/format).
372
+ persisted_fid = self._load_persisted_override() if session_fid is None else None
373
+ override_fid = session_fid if session_fid is not None else persisted_fid
374
+
375
+ if override_fid is not None:
376
+ log.info("dvdcompare lookup: using film id override fid=%s (source=%s)",
377
+ override_fid, "session" if session_fid is not None else "persisted")
378
+ film = asyncio.run(provider.fetch_film_by_id(override_fid))
379
+ # Persist on success so subsequent navigations / disc swaps
380
+ # auto-use the same fid.
381
+ if session_fid is not None:
382
+ self._save_persisted_override(override_fid)
383
+ else:
384
+ log.info("dvdcompare lookup: title=%r format=%r year=%r",
385
+ title, disc_format, year)
386
+ film = asyncio.run(provider.fetch_film(title, disc_format, year=year))
387
+
388
+ log.info("dvdcompare lookup: found %r (fid=%s, %d releases)",
245
389
  film.title if film else None,
390
+ film.film_id if film else None,
246
391
  len(film.releases) if film else 0)
247
392
  self.app.state["_dvdcompare_film"] = film
248
393
  except Exception as exc:
@@ -253,3 +253,46 @@ class TestBoxsetWithQuotedTitleDiscs:
253
253
  assert planned[5].is_film is True
254
254
  assert planned[6].is_film is False
255
255
  assert planned[7].is_film is False
256
+
257
+ class TestFilmUrl:
258
+ def test_film_url_int(self):
259
+ from riplex.disc.provider import film_url
260
+ assert film_url(55540) == "https://www.dvdcompare.net/comparisons/film.php?fid=55540"
261
+
262
+ def test_film_url_str(self):
263
+ from riplex.disc.provider import film_url
264
+ assert film_url("55540") == "https://www.dvdcompare.net/comparisons/film.php?fid=55540"
265
+
266
+
267
+ class TestParseFilmId:
268
+ def test_bare_digits(self):
269
+ from riplex.disc.provider import parse_film_id
270
+ assert parse_film_id("55540") == 55540
271
+
272
+ def test_full_url(self):
273
+ from riplex.disc.provider import parse_film_id
274
+ assert parse_film_id("https://www.dvdcompare.net/comparisons/film.php?fid=55540") == 55540
275
+
276
+ def test_url_with_anchor(self):
277
+ from riplex.disc.provider import parse_film_id
278
+ assert parse_film_id("https://www.dvdcompare.net/comparisons/film.php?fid=55540#2") == 55540
279
+
280
+ def test_query_fragment(self):
281
+ from riplex.disc.provider import parse_film_id
282
+ assert parse_film_id("fid=12345") == 12345
283
+
284
+ def test_whitespace_stripped(self):
285
+ from riplex.disc.provider import parse_film_id
286
+ assert parse_film_id(" 55540 ") == 55540
287
+
288
+ def test_garbage_returns_none(self):
289
+ from riplex.disc.provider import parse_film_id
290
+ assert parse_film_id("not a film id") is None
291
+
292
+ def test_empty_returns_none(self):
293
+ from riplex.disc.provider import parse_film_id
294
+ assert parse_film_id("") is None
295
+
296
+ def test_whitespace_only_returns_none(self):
297
+ from riplex.disc.provider import parse_film_id
298
+ assert parse_film_id(" ") is None
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
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
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
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
File without changes
File without changes
File without changes
File without changes
File without changes