From 1791a75f1510085c78eb7f79d03092fd0489917f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 24 Feb 2026 18:32:48 -0600 Subject: [PATCH] Bug Fix: Remove stale episode relations when series relations are deleted as well as remove episodes not seen in current series scan. Enhancement: Added series_relation FK to M3UEpisodeRelation table to properly link episodes with the m3useriesrelation they came from. --- CHANGELOG.md | 3 + apps/vod/admin.py | 2 +- ...0004_m3uepisoderelation_series_relation.py | 19 ++++++ apps/vod/models.py | 8 +++ apps/vod/tasks.py | 58 +++++++++++++------ 5 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 apps/vod/migrations/0004_m3uepisoderelation_series_relation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ab8110f..d259c634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Next Available**: Auto-assign starting from 1, skipping all used channel numbers Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433) - Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd) +- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup. ### Changed @@ -75,6 +76,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique. - Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil) +- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried. ## [0.19.0] - 2026-02-10 diff --git a/apps/vod/admin.py b/apps/vod/admin.py index c660f310..6dadab3e 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin): @admin.register(M3UEpisodeRelation) class M3UEpisodeRelationAdmin(admin.ModelAdmin): - list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] list_filter = ['m3u_account', 'created_at'] search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 00000000..e4973627 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index 7067856e..dd7d1753 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -261,6 +261,14 @@ class M3UEpisodeRelation(models.Model): """Links M3U accounts to Episodes with provider-specific information""" m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) # Streaming information (provider-specific) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 7055dbff..1b12c7b7 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1258,15 +1258,16 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N else: episodes_data = {} - # Process all episodes in batch - batch_process_episodes(account, series, episodes_data) - - # Update the series relation to mark episodes as fetched + # Fetch the series relation once — used both to pass into batch_process_episodes + # (so episode relations get the FK set) and to update metadata afterwards. series_relation = M3USeriesRelation.objects.filter( - series=series, - m3u_account=account + m3u_account=account, + external_series_id=external_series_id ).first() + # Process all episodes in batch + batch_process_episodes(account, series, episodes_data, series_relation=series_relation) + if series_relation: custom_props = series_relation.custom_properties or {} custom_props['episodes_fetched'] = True @@ -1279,13 +1280,18 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") -def batch_process_episodes(account, series, episodes_data, scan_start_time=None): +def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None): """Process episodes in batches for better performance. Note: Multiple streams can represent the same episode (e.g., different languages or qualities). Each stream has a unique stream_id, but they share the same season/episode number. We create one Episode record per (series, season, episode) and multiple M3UEpisodeRelation records pointing to it. + + series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so + that CASCADE correctly removes episode relations when their parent series relation + is deleted, and so that stale-stream cleanup is scoped precisely to relations that + came from this specific provider query. """ if not episodes_data: return @@ -1439,10 +1445,11 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing relation relation = existing_relations[episode_id] relation.episode = episode + relation.series_relation = series_relation relation.container_extension = episode_data.get('container_extension', 'mp4') relation.custom_properties = { 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, } relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan relations_to_update.append(relation) @@ -1451,11 +1458,12 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) relation = M3UEpisodeRelation( m3u_account=account, episode=episode, + series_relation=series_relation, stream_id=episode_id, container_extension=episode_data.get('container_extension', 'mp4'), custom_properties={ 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, }, last_seen=scan_start_time or timezone.now() # Mark as seen during this scan ) @@ -1518,9 +1526,28 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing episode relations if relations_to_update: M3UEpisodeRelation.objects.bulk_update(relations_to_update, [ - 'episode', 'container_extension', 'custom_properties', 'last_seen' + 'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen' ]) + # Delete relations for streams no longer returned by the provider. + # Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows + # for the same account+series (pre-migration rows whose stream is now gone — the + # update path only backfills the FK for streams still present in the response). + # Falls back to account+series scope when series_relation is None (shouldn't occur). + if series_relation is not None: + stale_qs = M3UEpisodeRelation.objects.filter( + Q(series_relation=series_relation) | + Q(series_relation__isnull=True, m3u_account=account, episode__series=series) + ) + else: + stale_qs = M3UEpisodeRelation.objects.filter( + m3u_account=account, + episode__series=series + ) + removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0] + if removed_count: + logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}") + logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, " f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations") @@ -1605,16 +1632,12 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= stale_movie_count = stale_movie_relations.count() stale_movie_relations.delete() - # Clean up stale series relations + # Clean up stale series relations. + # Episode relations are removed via CASCADE on the series_relation FK. stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) stale_series_count = stale_series_relations.count() stale_series_relations.delete() - # Clean up stale episode relations - stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters) - stale_episode_count = stale_episode_relations.count() - stale_episode_relations.delete() - # Clean up movies with no relations (orphaned) # Safe to delete even during account-specific cleanup because if ANY account # has a relation, m3u_relations will not be null @@ -1631,11 +1654,8 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") orphaned_series.delete() - # Episodes will be cleaned up via CASCADE when series are deleted - result = (f"Cleaned up {stale_movie_count} stale movie relations, " f"{stale_series_count} stale series relations, " - f"{stale_episode_count} stale episode relations, " f"{orphaned_movie_count} orphaned movies, and " f"{orphaned_series_count} orphaned series")