diff --git a/app/Filament/Resources/CustomPlaylists/CustomPlaylistResource.php b/app/Filament/Resources/CustomPlaylists/CustomPlaylistResource.php index 8f773425b..8f73cc26b 100644 --- a/app/Filament/Resources/CustomPlaylists/CustomPlaylistResource.php +++ b/app/Filament/Resources/CustomPlaylists/CustomPlaylistResource.php @@ -15,6 +15,7 @@ use App\Filament\Resources\CustomPlaylists\RelationManagers\VodRelationManager; use App\Jobs\DuplicateCustomPlaylist; use App\Models\CustomPlaylist; +use App\Models\Playlist; use App\Models\PlaylistAuth; use App\Models\StreamProfile; use App\Services\DateFormatService; @@ -31,6 +32,7 @@ use Filament\Actions\ViewAction; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; @@ -827,6 +829,109 @@ public static function getForm($creating = false): array return "{$actionLabel} — {$typeLabel}{$groupLabel}{$disabled}"; }), ]), + Section::make(__('Auto-Merge Channels')) + ->description(__('Automatically merge overlapping channels within this custom playlist into failover relationships after each sync. Only channels added to this custom playlist are considered.')) + ->columnSpanFull() + ->collapsible() + ->collapsed(true) + ->columns(2) + ->schema([ + Toggle::make('auto_merge_channels_enabled') + ->label(__('Enable auto-merge after sync')) + ->helperText(__('When enabled, channels in this custom playlist with the same stream ID will be automatically merged with failover relationships after each auto-sync.')) + ->columnSpanFull() + ->live() + ->inline(false) + ->default(false), + + Fieldset::make(__('Merge scope')) + ->columnSpanFull() + ->hidden(fn (Get $get): bool => ! $get('auto_merge_channels_enabled')) + ->schema([ + Select::make('auto_merge_config.groups') + ->label(__('Custom groups to merge')) + ->options(fn (?CustomPlaylist $record): array => [ + 'all' => __('All groups'), + ...($record + ? $record->groupTags()->pluck('name', 'name')->sort()->all() + : []), + ]) + ->default(['all']) + ->multiple() + ->searchable() + ->columnSpanFull() + ->helperText(__('Only channels in the selected custom playlist groups will be merged. Leave as "All groups" to merge across the whole custom playlist.')), + ]), + + Fieldset::make(__('Merge source configuration')) + ->columnSpanFull() + ->columns(2) + ->hidden(fn (Get $get): bool => ! $get('auto_merge_channels_enabled')) + ->schema([ + Select::make('auto_merge_config.preferred_playlist_id') + ->label(__('Preferred Playlist (optional)')) + ->options(fn () => Playlist::where('user_id', auth()->id())->pluck('name', 'id')) + ->searchable() + ->columnSpanFull() + ->placeholder(__('Use source priority order')) + ->helperText(__('If set, channels from this playlist will be prioritized as master during merge.')), + Repeater::make('auto_merge_config.failover_playlists') + ->label(__('Source playlist priority (optional)')) + ->columnSpanFull() + ->reorderable() + ->reorderableWithButtons() + ->defaultItems(0) + ->addActionLabel(__('Add playlist')) + ->helperText(__('Ordered list deciding which playlist wins as master when duplicates are found (first = highest priority). Leave empty to consider all source playlists of this custom playlist.')) + ->schema([ + Select::make('playlist_failover_id') + ->label(__('Playlist')) + ->options(fn () => Playlist::where('user_id', auth()->id())->pluck('name', 'id')) + ->searchable() + ->required(), + ]), + ]), + + Fieldset::make(__('Merge behavior')) + ->columnSpanFull() + ->columns(2) + ->hidden(fn (Get $get): bool => ! $get('auto_merge_channels_enabled')) + ->schema([ + TagsInput::make('auto_merge_config.regex_patterns') + ->label(__('Regex patterns (optional)')) + ->placeholder(__('e.g. ^(?:US[:\-\s])?(.*?)(?:\s+(?:HD|FHD|UHD|4K))?$')) + ->columnSpanFull() + ->helperText(__('Optional regex patterns used to group channels by name for merging (applied after stream ID merging).')), + Select::make('auto_merge_config.merge_key') + ->label(__('Merge key')) + ->options([ + 'stream_id' => __('Stream ID (Live)'), + 'tmdb_id' => __('TMDB ID (VOD)'), + ]) + ->default('stream_id') + ->helperText(__('Merge live channels by stream ID, or VOD channels by TMDB ID.')), + Toggle::make('auto_merge_config.check_resolution') + ->label(__('Prefer higher resolution')) + ->inline(false) + ->default(false) + ->helperText(__('Prefer higher-resolution streams as master (requires probed stream stats).')), + Toggle::make('auto_merge_config.prefer_catchup_as_primary') + ->label(__('Prefer catchup as primary')) + ->inline(false) + ->default(false) + ->helperText(__('Prefer channels with catchup support as master.')), + Toggle::make('auto_merge_config.force_complete_remerge') + ->label(__('Force complete re-merge')) + ->inline(false) + ->default(false) + ->helperText(__('Re-evaluate all channels on every run instead of skipping existing failovers.')), + Toggle::make('auto_merge_deactivate_failover') + ->label(__('Deactivate failover channels')) + ->inline(false) + ->default(false) + ->helperText(__('Disable channels that become failovers so only the master stays enabled.')), + ]), + ]), ]), Tab::make(__('Output')) ->icon('heroicon-m-arrow-up-right') diff --git a/app/Jobs/AutoSyncGroupsToCustomPlaylist.php b/app/Jobs/AutoSyncGroupsToCustomPlaylist.php index 5a5fb1fd0..c7b78d50d 100644 --- a/app/Jobs/AutoSyncGroupsToCustomPlaylist.php +++ b/app/Jobs/AutoSyncGroupsToCustomPlaylist.php @@ -208,7 +208,7 @@ public function handle(): void $notification->broadcast($user)->sendToDatabase($user); - if ($playlist->hasEnabledProcessingRules()) { + if ($playlist->hasEnabledProcessingRules() || $playlist->auto_merge_channels_enabled) { SyncCompleted::dispatch($playlist, 'custom_playlist'); } } diff --git a/app/Jobs/MergeChannels.php b/app/Jobs/MergeChannels.php index 484abe39a..32e1007bd 100644 --- a/app/Jobs/MergeChannels.php +++ b/app/Jobs/MergeChannels.php @@ -4,6 +4,7 @@ use App\Models\Channel; use App\Models\ChannelFailover; +use App\Models\CustomPlaylist; use App\Models\Group; use App\Models\Playlist; use App\Models\User; @@ -63,6 +64,12 @@ class MergeChannels implements ShouldQueue */ protected array $existingFailoverMasterIds = []; + /** + * Cached uuid of the scoped custom playlist (used as the tag type for + * custom group filtering). + */ + protected ?string $customPlaylistUuid = null; + /** * Create a new job instance. */ @@ -82,6 +89,8 @@ public function __construct( public string $contentType = 'live', public string $mergeKey = 'stream_id', public bool $scrubberAwareMasterSelection = false, + public ?int $customPlaylistId = null, + public ?array $customGroupNames = null, ) { $this->contentType = in_array($this->contentType, ['live', 'vod'], true) ? $this->contentType : 'live'; $this->mergeKey = in_array($this->mergeKey, ['stream_id', 'tmdb_id'], true) ? $this->mergeKey : 'stream_id'; @@ -149,6 +158,7 @@ public function handle(): void $this->applyContentTypeScope($allChannelsQuery); $this->applyMergeKeyPresenceScope($allChannelsQuery); + $this->applyCustomPlaylistScope($allChannelsQuery); $allChannels = $allChannelsQuery ->when($this->groupId, function ($query) { @@ -189,6 +199,40 @@ public function handle(): void $this->sendCompletionNotification($processed, $deactivatedCount); } + /** + * Restrict a channel candidate query to the scoped custom playlist and, + * optionally, its selected custom groups (issue #1103). Custom playlist + * groups are tags whose type is the custom playlist's uuid. + */ + protected function applyCustomPlaylistScope($query): void + { + if (! $this->customPlaylistId) { + return; + } + + $query->whereHas('customPlaylists', function ($q) { + $q->where('custom_playlists.id', $this->customPlaylistId); + }); + + $groupNames = collect($this->customGroupNames ?? [])->filter()->values(); + if ($groupNames->isEmpty() || $groupNames->contains('all')) { + return; + } + + $tagType = $this->customPlaylistUuid ??= CustomPlaylist::find($this->customPlaylistId)?->uuid; + if (! $tagType) { + return; + } + + $query->where(function ($q) use ($groupNames, $tagType): void { + foreach ($groupNames as $groupName) { + $q->orWhereHas('tags', function ($tagQuery) use ($groupName, $tagType): void { + $tagQuery->where('type', $tagType)->where('name->en', $groupName); + }); + } + }); + } + /** * Apply live or VOD filtering so failover groups never mix content types. */ @@ -319,6 +363,7 @@ protected function processFallbackNameMerges( ])->whereIn('playlist_id', $playlistIds); $this->applyContentTypeScope($channelsQuery); + $this->applyCustomPlaylistScope($channelsQuery); $channels = $channelsQuery ->where(function ($query) { @@ -362,6 +407,12 @@ protected function processRegexMerges(array $playlistIds, array $playlistPriorit $patterns = $this->regexPatterns ?? []; if (empty($patterns)) { + // Custom playlist merges carry their own config — never fall back to the + // primary source playlist's regex patterns. + if ($this->customPlaylistId) { + return ['processed' => 0, 'deactivated' => 0]; + } + // If patterns not set directly, check if playlist has auto_merge_config with regex_patterns $playlist = Playlist::find($this->playlistId); if ($playlist) { @@ -390,6 +441,7 @@ protected function processRegexMerges(array $playlistIds, array $playlistPriorit ->whereIn('playlist_id', $playlistIds); $this->applyContentTypeScope($regexChannelsQuery); + $this->applyCustomPlaylistScope($regexChannelsQuery); $regexChannelsQuery ->when($this->groupId, fn ($q) => $q->where('group_id', $this->groupId)) diff --git a/app/Listeners/SyncListener.php b/app/Listeners/SyncListener.php index 89b664628..44a7a4364 100644 --- a/app/Listeners/SyncListener.php +++ b/app/Listeners/SyncListener.php @@ -104,11 +104,85 @@ public function handle(SyncCompleted $event): void */ private function dispatchCustomPlaylistProcessing(CustomPlaylist $customPlaylist): void { - if (! $customPlaylist->hasEnabledProcessingRules()) { + $jobs = []; + + // Merge runs first so the processing rules (sort/recount) see the + // post-merge channel lineup. + if ($mergeJob = self::getCustomPlaylistMergeJob($customPlaylist)) { + $jobs[] = $mergeJob; + } + + if ($customPlaylist->hasEnabledProcessingRules()) { + $jobs[] = new RunCustomPlaylistProcessing($customPlaylist); + } + + if (empty($jobs)) { return; } - dispatch(new RunCustomPlaylistProcessing($customPlaylist)); + Bus::chain($jobs)->dispatch(); + } + + /** + * Build a MergeChannels job scoped to a custom playlist's channel set (issue #1103). + * + * Mirrors getMergeJob() but restricts merge candidates to channels attached to + * the custom playlist (optionally filtered to selected custom groups). The + * master-priority playlist order comes from the config, falling back to the + * distinct source playlists of the custom playlist's channels. + * + * Returns null if auto-merge is disabled or there is nothing to merge. + */ + public static function getCustomPlaylistMergeJob(CustomPlaylist $customPlaylist): ?MergeChannels + { + if (! $customPlaylist->auto_merge_channels_enabled) { + return null; + } + + $config = $customPlaylist->auto_merge_config ?? []; + + $playlists = collect($config['failover_playlists'] ?? []) + ->map(fn ($failover) => is_array($failover) ? ($failover['playlist_failover_id'] ?? null) : $failover) + ->filter() + ->map(fn ($id) => ['playlist_failover_id' => (int) $id]) + ->values(); + + if ($playlists->isEmpty()) { + $playlists = $customPlaylist->channels() + ->whereNotNull('playlist_id') + ->distinct() + ->pluck('playlist_id') + ->map(fn ($id) => ['playlist_failover_id' => (int) $id]) + ->values(); + } + + if ($playlists->isEmpty()) { + return null; + } + + $preferredPlaylistId = $config['preferred_playlist_id'] ?? null; + $effectivePlaylistId = $preferredPlaylistId + ? (int) $preferredPlaylistId + : (int) $playlists->first()['playlist_failover_id']; + + return new MergeChannels( + user: $customPlaylist->user, + playlists: $playlists, + playlistId: $effectivePlaylistId, + checkResolution: $config['check_resolution'] ?? false, + deactivateFailoverChannels: (bool) ($customPlaylist->auto_merge_deactivate_failover ?? false), + forceCompleteRemerge: $config['force_complete_remerge'] ?? false, + preferCatchupAsPrimary: $config['prefer_catchup_as_primary'] ?? false, + weightedConfig: self::buildWeightedConfig($config), + newChannelsOnly: $config['new_channels_only'] ?? false, + regexPatterns: ! empty($config['regex_patterns'] ?? []) ? $config['regex_patterns'] : null, + fallbackMergeConfig: PlaylistService::buildMergeFallbackConfig($config), + contentType: ($config['merge_key'] ?? 'stream_id') === 'tmdb_id' ? 'vod' : 'live', + mergeKey: $config['merge_key'] ?? 'stream_id', + scrubberAwareMasterSelection: (bool) ($config['scrubber_aware_master_selection'] ?? false), + customPlaylistId: $customPlaylist->id, + customGroupNames: $config['groups'] ?? null, + ); } /** diff --git a/app/Models/CustomPlaylist.php b/app/Models/CustomPlaylist.php index 60e8e56a0..4257584cf 100644 --- a/app/Models/CustomPlaylist.php +++ b/app/Models/CustomPlaylist.php @@ -46,6 +46,9 @@ class CustomPlaylist extends Model 'id_channel_by' => PlaylistChannelId::class, 'disable_m3u_xtream_format' => 'boolean', 'processing_config' => 'array', + 'auto_merge_channels_enabled' => 'boolean', + 'auto_merge_config' => 'array', + 'auto_merge_deactivate_failover' => 'boolean', ]; public function enabledProcessingRules(): SupportCollection diff --git a/database/migrations/2026_07_11_192500_add_auto_merge_to_custom_playlists_table.php b/database/migrations/2026_07_11_192500_add_auto_merge_to_custom_playlists_table.php new file mode 100644 index 000000000..b52aceed7 --- /dev/null +++ b/database/migrations/2026_07_11_192500_add_auto_merge_to_custom_playlists_table.php @@ -0,0 +1,28 @@ +boolean('auto_merge_channels_enabled')->default(false)->after('processing_config'); + $table->jsonb('auto_merge_config')->nullable()->after('auto_merge_channels_enabled'); + $table->boolean('auto_merge_deactivate_failover')->default(false)->after('auto_merge_config'); + }); + } + + public function down(): void + { + Schema::table('custom_playlists', function (Blueprint $table) { + $table->dropColumn([ + 'auto_merge_channels_enabled', + 'auto_merge_config', + 'auto_merge_deactivate_failover', + ]); + }); + } +}; diff --git a/tests/Feature/CustomPlaylistChannelMergeTest.php b/tests/Feature/CustomPlaylistChannelMergeTest.php new file mode 100644 index 000000000..454ee8dcd --- /dev/null +++ b/tests/Feature/CustomPlaylistChannelMergeTest.php @@ -0,0 +1,198 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + $this->playlistA = Playlist::factory()->createQuietly(['user_id' => $this->user->id]); + $this->playlistB = Playlist::factory()->createQuietly(['user_id' => $this->user->id]); + + $this->groupA = Group::factory()->createQuietly([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlistA->id, + ]); + $this->groupB = Group::factory()->createQuietly([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlistB->id, + ]); + + $this->customPlaylist = CustomPlaylist::factory()->create(['user_id' => $this->user->id]); +}); + +function makeMergeableChannel(Playlist $playlist, Group $group, string $streamId, float $sort = 1.0): Channel +{ + return Channel::factory()->create([ + 'user_id' => $playlist->user_id, + 'playlist_id' => $playlist->id, + 'group_id' => $group->id, + 'stream_id' => $streamId, + 'sort' => $sort, + 'enabled' => true, + 'can_merge' => true, + 'is_vod' => false, + ]); +} + +it('merges only channels attached to the custom playlist', function () { + // Same stream id in both playlists, attached to the custom playlist + $inCpMaster = makeMergeableChannel($this->playlistA, $this->groupA, 'sport.1', 1.0); + $inCpFailover = makeMergeableChannel($this->playlistB, $this->groupB, 'sport.1', 2.0); + $this->customPlaylist->channels()->attach([$inCpMaster->id, $inCpFailover->id]); + + // Same stream id duplicated outside the custom playlist — must be untouched + makeMergeableChannel($this->playlistA, $this->groupA, 'news.1', 1.0); + makeMergeableChannel($this->playlistB, $this->groupB, 'news.1', 2.0); + + $this->customPlaylist->update([ + 'auto_merge_channels_enabled' => true, + 'auto_merge_config' => [ + 'failover_playlists' => [ + ['playlist_failover_id' => $this->playlistA->id], + ['playlist_failover_id' => $this->playlistB->id], + ], + ], + ]); + + SyncListener::getCustomPlaylistMergeJob($this->customPlaylist->refresh())->handle(); + + $this->assertDatabaseCount('channel_failovers', 1); + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $inCpMaster->id, + 'channel_failover_id' => $inCpFailover->id, + ]); +}); + +it('restricts merging to the selected custom playlist groups', function () { + $sportsTag = Tag::create(['name' => ['en' => 'Sports'], 'type' => $this->customPlaylist->uuid]); + $newsTag = Tag::create(['name' => ['en' => 'News'], 'type' => $this->customPlaylist->uuid]); + + $sportsA = makeMergeableChannel($this->playlistA, $this->groupA, 'sport.1', 1.0); + $sportsB = makeMergeableChannel($this->playlistB, $this->groupB, 'sport.1', 2.0); + $newsA = makeMergeableChannel($this->playlistA, $this->groupA, 'news.1', 1.0); + $newsB = makeMergeableChannel($this->playlistB, $this->groupB, 'news.1', 2.0); + + $sportsA->attachTag($sportsTag); + $sportsB->attachTag($sportsTag); + $newsA->attachTag($newsTag); + $newsB->attachTag($newsTag); + + $this->customPlaylist->channels()->attach([$sportsA->id, $sportsB->id, $newsA->id, $newsB->id]); + + $this->customPlaylist->update([ + 'auto_merge_channels_enabled' => true, + 'auto_merge_config' => [ + 'groups' => ['Sports'], + 'failover_playlists' => [ + ['playlist_failover_id' => $this->playlistA->id], + ['playlist_failover_id' => $this->playlistB->id], + ], + ], + ]); + + SyncListener::getCustomPlaylistMergeJob($this->customPlaylist->refresh())->handle(); + + $this->assertDatabaseCount('channel_failovers', 1); + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $sportsA->id, + 'channel_failover_id' => $sportsB->id, + ]); + expect(ChannelFailover::where('channel_id', $newsA->id)->exists())->toBeFalse(); +}); + +it('selects the master channel from the configured playlist priority order', function () { + $fromA = makeMergeableChannel($this->playlistA, $this->groupA, 'sport.1', 1.0); + $fromB = makeMergeableChannel($this->playlistB, $this->groupB, 'sport.1', 2.0); + $this->customPlaylist->channels()->attach([$fromA->id, $fromB->id]); + + // Playlist B first → its channel should win as master despite higher sort + $this->customPlaylist->update([ + 'auto_merge_channels_enabled' => true, + 'auto_merge_config' => [ + 'failover_playlists' => [ + ['playlist_failover_id' => $this->playlistB->id], + ['playlist_failover_id' => $this->playlistA->id], + ], + ], + ]); + + SyncListener::getCustomPlaylistMergeJob($this->customPlaylist->refresh())->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $fromB->id, + 'channel_failover_id' => $fromA->id, + ]); +}); + +it('derives the source playlists from the custom playlist channels when not configured', function () { + $fromA = makeMergeableChannel($this->playlistA, $this->groupA, 'sport.1', 1.0); + $fromB = makeMergeableChannel($this->playlistB, $this->groupB, 'sport.1', 2.0); + $this->customPlaylist->channels()->attach([$fromA->id, $fromB->id]); + + $this->customPlaylist->update(['auto_merge_channels_enabled' => true]); + + $job = SyncListener::getCustomPlaylistMergeJob($this->customPlaylist->refresh()); + + expect($job)->not->toBeNull() + ->and($job->customPlaylistId)->toBe($this->customPlaylist->id) + ->and($job->playlists->pluck('playlist_failover_id')->sort()->values()->all()) + ->toEqual(collect([$this->playlistA->id, $this->playlistB->id])->sort()->values()->all()); + + $job->handle(); + + $this->assertDatabaseCount('channel_failovers', 1); +}); + +it('returns no merge job when auto-merge is disabled or the custom playlist is empty', function () { + expect(SyncListener::getCustomPlaylistMergeJob($this->customPlaylist))->toBeNull(); + + $this->customPlaylist->update(['auto_merge_channels_enabled' => true]); + + // Enabled but no channels and no configured playlists → still null + expect(SyncListener::getCustomPlaylistMergeJob($this->customPlaylist->refresh()))->toBeNull(); +}); + +it('chains the merge job before processing rules on custom playlist sync completion', function () { + Bus::fake(); + + $channel = makeMergeableChannel($this->playlistA, $this->groupA, 'sport.1'); + $this->customPlaylist->channels()->attach($channel->id); + + $this->customPlaylist->update([ + 'auto_merge_channels_enabled' => true, + 'processing_config' => [ + ['enabled' => true, 'action' => 'sort_alpha', 'type' => 'all', 'groups' => ['all'], 'column' => 'title', 'sort' => 'ASC'], + ], + ]); + + (new SyncListener)->handle(new SyncCompleted($this->customPlaylist->refresh(), 'custom_playlist')); + + Bus::assertChained([ + MergeChannels::class, + RunCustomPlaylistProcessing::class, + ]); +});