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.
This commit is contained in:
SergeantPanda 2026-02-24 18:32:48 -06:00
parent dca72f330e
commit 1791a75f15
5 changed files with 70 additions and 20 deletions

View file

@ -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

View file

@ -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']

View file

@ -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'),
),
]

View file

@ -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")

View file

@ -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")