Skip to content

Commit b5393d6

Browse files
committed
Add safe TV and Anime library migration
Preserve tracked history across TV/Anime identity moves, improve provider search ranking, and keep lock/provider failures recoverable. Fixes #922
1 parent 5a494ee commit b5393d6

12 files changed

Lines changed: 1339 additions & 23 deletions

File tree

src/app/credits.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ def _as_int(value):
5959
return None
6060

6161

62+
def _as_text(value):
63+
"""Normalize provider text fields that may arrive as non-string values."""
64+
return str(value or "").strip()
65+
66+
6267
def is_regular_show_cast_credit(source, sort_order):
6368
"""Return whether a show-level cast credit should count as series-regular fallback."""
6469
if source != Sources.TMDB.value:
@@ -236,14 +241,14 @@ def _normalize_credit_rows(rows):
236241
normalized.append(
237242
{
238243
"person_id": str(person_id),
239-
"name": (row.get("name") or "").strip(),
240-
"image": (row.get("image") or "").strip(),
241-
"known_for_department": (row.get("known_for_department") or "").strip(),
244+
"name": _as_text(row.get("name")),
245+
"image": _as_text(row.get("image")),
246+
"known_for_department": _as_text(row.get("known_for_department")),
242247
"gender": _coerce_gender(row.get("gender")),
243-
"role": (
248+
"role": _as_text(
244249
row.get("role") or row.get("character") or row.get("job") or ""
245-
).strip(),
246-
"department": (row.get("department") or "").strip(),
250+
),
251+
"department": _as_text(row.get("department")),
247252
"sort_order": _as_int(
248253
row["order"]
249254
if "order" in row and row["order"] is not None
@@ -263,8 +268,8 @@ def _normalize_studio_rows(rows):
263268
normalized.append(
264269
{
265270
"studio_id": str(studio_id),
266-
"name": (row.get("name") or "").strip(),
267-
"logo": (row.get("logo") or "").strip(),
271+
"name": _as_text(row.get("name")),
272+
"logo": _as_text(row.get("logo")),
268273
"sort_order": _as_int(
269274
row["order"]
270275
if "order" in row and row["order"] is not None
@@ -394,14 +399,14 @@ def _normalize_author_rows(rows):
394399
normalized.append(
395400
{
396401
"person_id": str(person_id),
397-
"name": (row.get("name") or "").strip(),
398-
"image": (row.get("image") or "").strip(),
399-
"known_for_department": (
402+
"name": _as_text(row.get("name")),
403+
"image": _as_text(row.get("image")),
404+
"known_for_department": _as_text(
400405
row.get("known_for_department") or row.get("department") or "Author"
401-
).strip(),
406+
),
402407
"gender": _coerce_gender(row.get("gender")),
403-
"role": (row.get("role") or "").strip(),
404-
"department": (row.get("department") or "").strip(),
408+
"role": _as_text(row.get("role")),
409+
"department": _as_text(row.get("department")),
405410
"sort_order": _as_int(
406411
row["order"]
407412
if "order" in row and row["order"] is not None
@@ -475,16 +480,14 @@ def upsert_person_profile(source, source_person_id, metadata):
475480
source=source,
476481
source_person_id=str(source_person_id),
477482
defaults={
478-
"name": (metadata.get("name") or "").strip() or "Unknown Person",
479-
"image": (metadata.get("image") or "").strip(),
480-
"known_for_department": (
481-
metadata.get("known_for_department") or ""
482-
).strip(),
483-
"biography": (metadata.get("biography") or "").strip(),
483+
"name": _as_text(metadata.get("name")) or "Unknown Person",
484+
"image": _as_text(metadata.get("image")),
485+
"known_for_department": _as_text(metadata.get("known_for_department")),
486+
"biography": _as_text(metadata.get("biography")),
484487
"gender": _coerce_gender(metadata.get("gender")),
485488
"birth_date": _coerce_iso_date(metadata.get("birth_date")),
486489
"death_date": _coerce_iso_date(metadata.get("death_date")),
487-
"place_of_birth": (metadata.get("place_of_birth") or "").strip(),
490+
"place_of_birth": _as_text(metadata.get("place_of_birth")),
488491
},
489492
)
490493
return person

src/app/metadata_sync_views.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from django.contrib.auth.decorators import login_required
99
from django.core.cache import cache
1010
from django.db.models import Max, Min
11+
from django.db.utils import OperationalError
1112
from django.http import HttpResponse
1213
from django.shortcuts import get_object_or_404, redirect, render
14+
from django.urls import reverse
1315
from django.utils import timezone
1416
from django.utils.http import url_has_allowed_host_and_scheme
1517
from django.views.decorators.http import require_GET, require_POST
@@ -21,6 +23,7 @@
2123
metadata_utils,
2224
view_constants,
2325
)
26+
from app.db_retry import is_retryable_error, run_retryable_db_operation
2427
from app.log_safety import exception_summary, safe_url
2528
from app.models import (
2629
CollectionEntry,
@@ -35,6 +38,7 @@
3538
from app.services import (
3639
anime_migration,
3740
bulk_episode_tracking,
41+
library_migration,
3842
metadata_resolution,
3943
)
4044
from app.services import game_lengths as game_length_services
@@ -174,6 +178,126 @@ def search_remap_candidates(request, source, media_type, media_id):
174178
)
175179

176180

181+
@login_required
182+
@require_GET
183+
def search_library_move_candidates(request, item_id):
184+
"""Search the configured destination provider for a library move."""
185+
item = get_object_or_404(Item, id=item_id)
186+
move_context = library_migration.get_move_context(request.user, item)
187+
query = (request.GET.get("q") or "").strip()
188+
results = []
189+
if move_context and query:
190+
try:
191+
response = services.search(
192+
move_context["target_media_type"],
193+
query,
194+
page=1,
195+
source=move_context["target_source"],
196+
user=request.user,
197+
language=metadata_resolution.metadata_language_default(
198+
request.user,
199+
),
200+
)
201+
results = (response or {}).get("results") or []
202+
except services.ProviderAPIError:
203+
logger.warning(
204+
"Destination library search failed for item %s",
205+
item_id,
206+
exc_info=True,
207+
)
208+
209+
return render(
210+
request,
211+
"app/components/library_move_results.html",
212+
{
213+
"results": results,
214+
"item_id": item.id,
215+
"query": query,
216+
"return_url": helpers.normalize_navigation_url(
217+
request.GET.get("return_url"),
218+
),
219+
**(move_context or {}),
220+
},
221+
)
222+
223+
224+
@login_required
225+
@require_POST
226+
def move_library_item(request, item_id):
227+
"""Move one owned TV/Anime show into an explicit destination identity."""
228+
item = get_object_or_404(Item, id=item_id)
229+
return_url = helpers.normalize_navigation_url(request.POST.get("return_url"))
230+
target_media_type = request.POST.get("target_media_type")
231+
target_source = request.POST.get("target_source")
232+
target_media_id = request.POST.get("target_media_id")
233+
is_htmx = request.headers.get("HX-Request") == "true"
234+
try:
235+
target_item = run_retryable_db_operation(
236+
lambda: library_migration.migrate_library_item(
237+
request.user,
238+
item,
239+
target_media_type,
240+
target_source,
241+
target_media_id,
242+
),
243+
operation_name="TV/Anime library migration",
244+
).value
245+
except OperationalError as error:
246+
if not is_retryable_error(error):
247+
raise
248+
if is_htmx:
249+
return render(
250+
request,
251+
"app/components/library_move_results.html",
252+
{
253+
"error": (
254+
"The database is busy with another operation. "
255+
"Nothing was changed; please try Move again."
256+
),
257+
},
258+
)
259+
raise
260+
except library_migration.LibraryMigrationError as error:
261+
if is_htmx:
262+
return render(
263+
request,
264+
"app/components/library_move_results.html",
265+
{"error": str(error)},
266+
)
267+
messages.error(request, str(error))
268+
if return_url and url_has_allowed_host_and_scheme(
269+
return_url,
270+
allowed_hosts=None,
271+
):
272+
return redirect(return_url)
273+
return redirect(
274+
"media_details",
275+
source=item.source,
276+
media_type=library_migration.library_bucket(item),
277+
media_id=item.media_id,
278+
title=item.get_display_title(request.user) or "item",
279+
)
280+
281+
messages.success(
282+
request,
283+
f"Moved tracking to {target_item.get_display_title(request.user) or 'the selected title'}.",
284+
)
285+
destination_url = reverse(
286+
"media_details",
287+
kwargs={
288+
"source": target_item.source,
289+
"media_type": library_migration.library_bucket(target_item),
290+
"media_id": target_item.media_id,
291+
"title": target_item.get_display_title(request.user) or "item",
292+
},
293+
)
294+
if is_htmx:
295+
response = HttpResponse(status=204)
296+
response["HX-Redirect"] = destination_url
297+
return response
298+
return redirect(destination_url)
299+
300+
177301
@login_required
178302
@require_POST
179303
def remap_metadata_provider(request, source, media_type, media_id):

src/app/providers/tvdb.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import re
67
from datetime import datetime
78
from typing import Any
89

@@ -531,6 +532,31 @@ def _get_title_fields(row: dict | None, language: str | None = None):
531532
}
532533

533534

535+
def _search_title_key(value) -> str:
536+
"""Normalize a title for provider-search relevance comparisons."""
537+
return re.sub(r"\s+", " ", re.sub(r"[^\w]+", " ", str(value or "").casefold())).strip()
538+
539+
540+
def _search_result_rank(result: dict, query: str) -> tuple[int, int]:
541+
"""Rank direct title matches ahead of TVDB's broader search matches."""
542+
normalized_query = _search_title_key(query)
543+
if not normalized_query:
544+
return (0, 0)
545+
546+
titles = {
547+
_search_title_key(result.get(field))
548+
for field in ("title", "localized_title", "original_title")
549+
}
550+
titles.discard("")
551+
if normalized_query in titles:
552+
return (0, 0)
553+
if any(title.startswith(normalized_query) for title in titles):
554+
return (1, 0)
555+
if any(normalized_query in title for title in titles):
556+
return (2, 0)
557+
return (3, 0)
558+
559+
534560
def _artwork_image(artwork: dict) -> str | None:
535561
"""Return the image URL from a single TVDB artwork entry."""
536562
for key in ("image", "thumbnail", "url"):
@@ -1197,7 +1223,7 @@ def search_remote_id(remote_id: str):
11971223
def search(media_type, query, page, language=None):
11981224
"""Search TVDB for TV or grouped anime titles."""
11991225
cache_key = _cache_key(
1200-
"search", media_type, query, page, _preferred_language_code(language)
1226+
"search_v2", media_type, query, page, _preferred_language_code(language)
12011227
)
12021228
data = cache.get(cache_key)
12031229
if data is not None:
@@ -1240,6 +1266,8 @@ def search(media_type, query, page, language=None):
12401266
if result["media_id"]:
12411267
normalized_results.append(result)
12421268

1269+
normalized_results.sort(key=lambda result: _search_result_rank(result, query))
1270+
12431271
data = helpers.format_search_response(
12441272
page, 20, len(normalized_results), normalized_results
12451273
)

0 commit comments

Comments
 (0)