mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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:
parent
dca72f330e
commit
1791a75f15
5 changed files with 70 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue