Skip to content

Commit f50d3d8

Browse files
committed
Add optional metadata cleanup to Danger Zone bulk delete
- bulk_delete_by_media_type now accepts delete_metadata=true, removing Item rows (including cascaded Season/Episode items for tv/anime) that are no longer tracked by anyone once the user's rows are deleted. - For music, also cleans up orphaned Artist/Album catalog rows, which have no FK from Item at all. - Adds an opt-in toggle to the Danger Zone confirm modal explaining the cascade to custom lists/collections/calendar entries. Fixes #971
1 parent 13ba2d3 commit f50d3d8

3 files changed

Lines changed: 282 additions & 7 deletions

File tree

src/templates/users/advanced.html

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ <h3 class="text-lg font-semibold text-[var(--color-error-text)]">Clear All Cache
364364
</template>
365365
</div>
366366

367-
<div class="bg-[var(--color-surface-strong)] p-5 rounded-lg mb-6" x-data="{ open: false, confirmOpen: false, selectedMediaType: '', selectedMediaLabel: 'Choose a media type...', deleteConfirmation: '', options: [
367+
<div class="bg-[var(--color-surface-strong)] p-5 rounded-lg mb-6" x-data="{ open: false, confirmOpen: false, selectedMediaType: '', selectedMediaLabel: 'Choose a media type...', deleteConfirmation: '', deleteMetadata: false, options: [
368368
{% for media_type in media_types %}
369369
{ value: '{{ media_type|escapejs }}', label: '{{ media_type|media_type_readable_plural|escapejs }}' }
370370
{% if not forloop.last %},{% endif %}
@@ -433,6 +433,7 @@ <h3 class="text-base font-medium text-[var(--color-error-text)]">Danger Zone</h3
433433
class="hidden">
434434
{% csrf_token %}
435435
<input type="hidden" name="media_type" x-bind:value="selectedMediaType">
436+
<input type="hidden" name="delete_metadata" x-bind:value="deleteMetadata">
436437
</form>
437438

438439
<template x-teleport="body">
@@ -452,6 +453,21 @@ <h3 class="text-lg font-semibold text-[var(--color-error-text)]">Are you sure?</
452453
<strong x-text="selectedMediaLabel.toLowerCase()"></strong>
453454
entry in your account. This removes its progress, ratings, notes, and history.
454455
</p>
456+
<label class="flex items-center justify-between p-3 mb-3 bg-[var(--color-panel)] rounded-md cursor-pointer">
457+
<span class="text-sm text-[var(--color-text-secondary)] pr-3">
458+
Also delete cached metadata (posters, cast/crew, and for music, artists &amp; albums)
459+
with no remaining tracked entries.
460+
</span>
461+
<span class="relative inline-flex items-center shrink-0">
462+
<input class="sr-only peer" type="checkbox" x-model="deleteMetadata">
463+
<div class="w-9 h-5 bg-gray-600 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-px after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4.5 after:w-4.5 after:transition-all peer-checked:bg-indigo-600">
464+
</div>
465+
</span>
466+
</label>
467+
<p x-show="deleteMetadata" x-cloak class="text-sm text-[var(--color-error-text)] mb-3">
468+
Metadata deletion also removes any custom list entries, collections, or calendar
469+
events still pointing at that now-untracked media.
470+
</p>
455471
<p class="text-sm text-[var(--color-error-text)] font-medium mb-4">This action cannot be undone.</p>
456472
<label class="block text-sm text-[var(--color-text-secondary)] mb-2">
457473
Type <strong>DELETE</strong> to confirm.

src/users/tests/views/test_advanced.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,20 @@
99
from app.history_cache_utils import HISTORY_DAY_PREFIX, HISTORY_INDEX_PREFIX
1010
from app.models import (
1111
TV,
12+
Album,
13+
AlbumTracker,
14+
Artist,
15+
ArtistTracker,
1216
Book,
1317
DeletedMedia,
1418
DiscoverRowCache,
1519
DiscoverTasteProfile,
20+
Episode,
1621
Item,
1722
MediaTypes,
1823
Movie,
24+
Music,
25+
Season,
1926
Sources,
2027
Status,
2128
)
@@ -351,6 +358,162 @@ def test_clear_all_caches_clears_search_and_current_users_own_caches(self):
351358
self.assertEqual(len(messages), 1)
352359
self.assertIn("search, history, statistics, and discover", str(messages[0]))
353360

361+
def test_delete_media_type_without_metadata_flag_leaves_item_behind(self):
362+
"""The default (unchecked) behavior must not change: Item rows survive."""
363+
item = Item.objects.create(
364+
media_id="orphan-movie",
365+
source=Sources.TMDB.value,
366+
media_type=MediaTypes.MOVIE.value,
367+
title="Orphan",
368+
)
369+
Movie.objects.create(item=item, user=self.user, status=Status.PLANNING.value)
370+
371+
response = self.client.post(
372+
reverse("bulk_delete_by_media_type"),
373+
{"media_type": MediaTypes.MOVIE.value},
374+
)
375+
376+
self.assertRedirects(response, reverse("advanced"))
377+
self.assertTrue(Item.objects.filter(id=item.id).exists())
378+
379+
def test_delete_media_type_with_metadata_flag_removes_orphaned_item(self):
380+
"""With delete_metadata=true, an Item with no other tracker is deleted."""
381+
item = Item.objects.create(
382+
media_id="orphan-movie",
383+
source=Sources.TMDB.value,
384+
media_type=MediaTypes.MOVIE.value,
385+
title="Orphan",
386+
)
387+
Movie.objects.create(item=item, user=self.user, status=Status.PLANNING.value)
388+
389+
response = self.client.post(
390+
reverse("bulk_delete_by_media_type"),
391+
{"media_type": MediaTypes.MOVIE.value, "delete_metadata": "true"},
392+
)
393+
394+
self.assertRedirects(response, reverse("advanced"))
395+
self.assertFalse(Item.objects.filter(id=item.id).exists())
396+
messages = list(get_messages(response.wsgi_request))
397+
self.assertIn("Also removed 1 metadata entry", str(messages[0]))
398+
399+
def test_delete_media_type_with_metadata_flag_keeps_item_tracked_by_other_user(self):
400+
"""An Item still tracked by another user must survive metadata cleanup."""
401+
item = Item.objects.create(
402+
media_id="shared-movie",
403+
source=Sources.TMDB.value,
404+
media_type=MediaTypes.MOVIE.value,
405+
title="Shared",
406+
)
407+
Movie.objects.create(item=item, user=self.user, status=Status.PLANNING.value)
408+
Movie.objects.create(
409+
item=item,
410+
user=self.other_user,
411+
status=Status.PLANNING.value,
412+
)
413+
414+
response = self.client.post(
415+
reverse("bulk_delete_by_media_type"),
416+
{"media_type": MediaTypes.MOVIE.value, "delete_metadata": "true"},
417+
)
418+
419+
self.assertRedirects(response, reverse("advanced"))
420+
self.assertTrue(Item.objects.filter(id=item.id).exists())
421+
422+
def test_delete_tv_with_metadata_flag_cleans_up_seasons_and_episodes(self):
423+
"""Deleting TV with metadata on also removes now-orphaned Season/Episode Items."""
424+
tv_item = Item.objects.create(
425+
media_id="tv-show",
426+
source=Sources.TVDB.value,
427+
media_type=MediaTypes.TV.value,
428+
library_media_type=MediaTypes.TV.value,
429+
title="Show",
430+
)
431+
tv = TV.objects.create(item=tv_item, user=self.user, status=Status.PLANNING.value)
432+
season_item = Item.objects.create(
433+
media_id="tv-show",
434+
source=Sources.TVDB.value,
435+
media_type=MediaTypes.SEASON.value,
436+
season_number=1,
437+
title="Show",
438+
)
439+
season = Season.objects.create(item=season_item, user=self.user, related_tv=tv)
440+
episode_item = Item.objects.create(
441+
media_id="tv-show",
442+
source=Sources.TVDB.value,
443+
media_type=MediaTypes.EPISODE.value,
444+
season_number=1,
445+
episode_number=1,
446+
title="Show",
447+
)
448+
Episode.objects.create(item=episode_item, related_season=season)
449+
450+
response = self.client.post(
451+
reverse("bulk_delete_by_media_type"),
452+
{"media_type": MediaTypes.TV.value, "delete_metadata": "true"},
453+
)
454+
455+
self.assertRedirects(response, reverse("advanced"))
456+
self.assertFalse(Item.objects.filter(id=tv_item.id).exists())
457+
self.assertFalse(Item.objects.filter(id=season_item.id).exists())
458+
self.assertFalse(Item.objects.filter(id=episode_item.id).exists())
459+
460+
def test_delete_music_with_metadata_flag_cleans_orphaned_artist_and_album(self):
461+
"""Deleting music with metadata on also removes orphaned Artist/Album rows."""
462+
artist = Artist.objects.create(name="Orphan Artist")
463+
album = Album.objects.create(title="Orphan Album", artist=artist)
464+
item = Item.objects.create(
465+
media_id="orphan-track",
466+
source=Sources.MANUAL.value,
467+
media_type=MediaTypes.MUSIC.value,
468+
title="Track",
469+
)
470+
Music.objects.create(
471+
item=item,
472+
user=self.user,
473+
status=Status.COMPLETED.value,
474+
artist=artist,
475+
album=album,
476+
)
477+
478+
response = self.client.post(
479+
reverse("bulk_delete_by_media_type"),
480+
{"media_type": MediaTypes.MUSIC.value, "delete_metadata": "true"},
481+
)
482+
483+
self.assertRedirects(response, reverse("advanced"))
484+
self.assertFalse(Item.objects.filter(id=item.id).exists())
485+
self.assertFalse(Album.objects.filter(id=album.id).exists())
486+
self.assertFalse(Artist.objects.filter(id=artist.id).exists())
487+
488+
def test_delete_music_with_metadata_flag_keeps_artist_with_tracker(self):
489+
"""An Artist/Album still followed via ArtistTracker/AlbumTracker survives."""
490+
artist = Artist.objects.create(name="Followed Artist")
491+
album = Album.objects.create(title="Followed Album", artist=artist)
492+
ArtistTracker.objects.create(user=self.other_user, artist=artist)
493+
AlbumTracker.objects.create(user=self.other_user, album=album)
494+
item = Item.objects.create(
495+
media_id="tracked-track",
496+
source=Sources.MANUAL.value,
497+
media_type=MediaTypes.MUSIC.value,
498+
title="Track",
499+
)
500+
Music.objects.create(
501+
item=item,
502+
user=self.user,
503+
status=Status.COMPLETED.value,
504+
artist=artist,
505+
album=album,
506+
)
507+
508+
response = self.client.post(
509+
reverse("bulk_delete_by_media_type"),
510+
{"media_type": MediaTypes.MUSIC.value, "delete_metadata": "true"},
511+
)
512+
513+
self.assertRedirects(response, reverse("advanced"))
514+
self.assertTrue(Album.objects.filter(id=album.id).exists())
515+
self.assertTrue(Artist.objects.filter(id=artist.id).exists())
516+
354517
def test_cache_clear_views_require_post(self):
355518
"""GET requests to any clear-cache endpoint must be rejected."""
356519
for url_name in (

src/users/views.py

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from app import history_cache, image_cache, statistics_cache
2929
from app.discover.feeds import get_external_row_definitions
3030
from app.discover.registry import DISCOVER_MEDIA_TYPES
31-
from app.models import Item, MediaTypes, Status
31+
from app.models import Album, Artist, Item, MediaTypes, Status
3232
from app.providers import tmdb
3333
from app.services import metadata_resolution
3434
from app.templatetags import app_tags
@@ -76,6 +76,7 @@
7676
TimeFormatChoices,
7777
TitleDisplayPreferenceChoices,
7878
TopTalentSortChoices,
79+
UiLanguageChoices,
7980
User,
8081
WeekStartDayChoices,
8182
)
@@ -901,6 +902,7 @@ def preferences(request):
901902
selected_media_types = request.POST.getlist("media_types_checkboxes")
902903
date_format = request.POST.get("date_format")
903904
theme = request.POST.get("theme")
905+
ui_language = request.POST.get("ui_language")
904906
logo_style = request.POST.get("logo_style")
905907
time_format = request.POST.get("time_format")
906908
activity_history_view = request.POST.get("activity_history_view")
@@ -981,6 +983,14 @@ def preferences(request):
981983
request.user.logo_style = logo_style
982984
fields_to_update.append("logo_style")
983985

986+
if (
987+
ui_language
988+
and ui_language in UiLanguageChoices.values
989+
and request.user.ui_language != ui_language
990+
):
991+
request.user.ui_language = ui_language
992+
fields_to_update.append("ui_language")
993+
984994
if (
985995
time_format
986996
and time_format in [choice[0] for choice in TimeFormatChoices.choices]
@@ -1230,6 +1240,7 @@ def preferences(request):
12301240
"library_labels_json": json.dumps(library_labels),
12311241
"watch_provider_choices": watch_provider_regions,
12321242
"metadata_language_choices": metadata_language_choices,
1243+
"ui_language_choices": UiLanguageChoices.choices,
12331244
"tv_metadata_source_choices": tv_metadata_source_choices,
12341245
"anime_metadata_source_choices": anime_metadata_source_choices,
12351246
"anime_library_mode_choices": AnimeLibraryModeChoices.choices,
@@ -1727,11 +1738,22 @@ def bulk_delete_by_media_type(request):
17271738
messages.error(request, "Unknown media type.")
17281739
return redirect("advanced")
17291740

1741+
delete_metadata = request.POST.get("delete_metadata") == "true"
1742+
17301743
media_querysets = _media_querysets_for_bulk_delete(request.user, media_type)
17311744
item_count = sum(queryset.count() for queryset in media_querysets)
1745+
candidate_item_ids = (
1746+
_candidate_item_ids_for_metadata_cleanup(media_querysets, media_type)
1747+
if delete_metadata
1748+
else set()
1749+
)
17321750
for queryset in media_querysets:
17331751
queryset.delete()
17341752

1753+
metadata_count = 0
1754+
if delete_metadata and candidate_item_ids:
1755+
metadata_count = _delete_orphaned_metadata(media_type, candidate_item_ids)
1756+
17351757
if item_count:
17361758
# Model post-delete signals invalidate runtime caches and schedule
17371759
# refreshes. These payload clears ensure a just-deleted item cannot
@@ -1740,14 +1762,16 @@ def bulk_delete_by_media_type(request):
17401762
cache_management.clear_statistics_cache_for_user(request.user.id)
17411763
cache_management.clear_discover_cache_for_user(request.user.id)
17421764
label = MediaTypes(media_type).label
1743-
messages.success(
1744-
request,
1745-
f"Permanently deleted {item_count} {label} item(s) from your library.",
1746-
)
1765+
message = f"Permanently deleted {item_count} {label} item(s) from your library."
1766+
if delete_metadata:
1767+
message += f" Also removed {metadata_count} metadata entr{'y' if metadata_count == 1 else 'ies'}."
1768+
messages.success(request, message)
17471769
logger.info(
1748-
"Permanently deleted %s %s items for user %s",
1770+
"Permanently deleted %s %s items (metadata=%s, metadata_count=%s) for user %s",
17491771
item_count,
17501772
media_type,
1773+
delete_metadata,
1774+
metadata_count,
17511775
request.user.id,
17521776
)
17531777
else:
@@ -1778,6 +1802,78 @@ def _media_querysets_for_bulk_delete(user, media_type):
17781802
return [queryset]
17791803

17801804

1805+
def _candidate_item_ids_for_metadata_cleanup(media_querysets, media_type):
1806+
"""Return Item ids that may become orphaned once media_querysets are deleted.
1807+
1808+
Must be called before the querysets are deleted. TV/anime rows cascade-delete
1809+
their Season and Episode rows, so those Items are candidates too even though
1810+
they aren't directly represented by media_querysets.
1811+
"""
1812+
item_ids = set()
1813+
for queryset in media_querysets:
1814+
item_ids.update(queryset.values_list("item_id", flat=True))
1815+
1816+
if media_type in (MediaTypes.TV.value, MediaTypes.ANIME.value):
1817+
season_model = apps.get_model(app_label="app", model_name="season")
1818+
episode_model = apps.get_model(app_label="app", model_name="episode")
1819+
for tv_queryset in media_querysets:
1820+
seasons = season_model.objects.filter(related_tv__in=tv_queryset)
1821+
item_ids.update(seasons.values_list("item_id", flat=True))
1822+
item_ids.update(
1823+
episode_model.objects.filter(
1824+
related_season__related_tv__in=tv_queryset,
1825+
).values_list("item_id", flat=True),
1826+
)
1827+
1828+
return item_ids
1829+
1830+
1831+
def _delete_orphaned_metadata(media_type, item_ids):
1832+
"""Delete Item rows (and music catalog rows) no longer tracked by anyone."""
1833+
tracking_models = [apps.get_model(app_label="app", model_name=media_type)]
1834+
if media_type in (MediaTypes.TV.value, MediaTypes.ANIME.value):
1835+
tracking_models = [
1836+
apps.get_model(app_label="app", model_name="tv"),
1837+
apps.get_model(app_label="app", model_name="anime"),
1838+
apps.get_model(app_label="app", model_name="season"),
1839+
apps.get_model(app_label="app", model_name="episode"),
1840+
]
1841+
1842+
orphaned_ids = set(item_ids)
1843+
for model in tracking_models:
1844+
manager = getattr(model, "all_objects", model.objects)
1845+
orphaned_ids -= set(
1846+
manager.filter(item_id__in=item_ids).values_list("item_id", flat=True),
1847+
)
1848+
1849+
item_count, _ = Item.objects.filter(id__in=orphaned_ids).delete()
1850+
1851+
if media_type == MediaTypes.MUSIC.value:
1852+
item_count += _delete_orphaned_music_catalog()
1853+
1854+
return item_count
1855+
1856+
1857+
def _delete_orphaned_music_catalog():
1858+
"""Delete Artist/Album catalog rows no longer referenced by anyone.
1859+
1860+
Track has no independent lifecycle -- it cascades when its Album is deleted.
1861+
"""
1862+
album_count, _ = Album.objects.filter(
1863+
music_entries__isnull=True,
1864+
trackers__isnull=True,
1865+
).delete()
1866+
artist_count, _ = Artist.objects.filter(
1867+
music_entries__isnull=True,
1868+
trackers__isnull=True,
1869+
albums__isnull=True,
1870+
album_credits__isnull=True,
1871+
members__isnull=True,
1872+
bands__isnull=True,
1873+
).delete()
1874+
return album_count + artist_count
1875+
1876+
17811877
@require_POST
17821878
def cancel_import_run(request, run_id):
17831879
"""Cancel a running import.

0 commit comments

Comments
 (0)