Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions app/Filament/Resources/CustomPlaylists/CustomPlaylistResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion app/Jobs/AutoSyncGroupsToCustomPlaylist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Expand Down
52 changes: 52 additions & 0 deletions app/Jobs/MergeChannels.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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';
Expand Down Expand Up @@ -149,6 +158,7 @@ public function handle(): void

$this->applyContentTypeScope($allChannelsQuery);
$this->applyMergeKeyPresenceScope($allChannelsQuery);
$this->applyCustomPlaylistScope($allChannelsQuery);

$allChannels = $allChannelsQuery
->when($this->groupId, function ($query) {
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -319,6 +363,7 @@ protected function processFallbackNameMerges(
])->whereIn('playlist_id', $playlistIds);

$this->applyContentTypeScope($channelsQuery);
$this->applyCustomPlaylistScope($channelsQuery);

$channels = $channelsQuery
->where(function ($query) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down
78 changes: 76 additions & 2 deletions app/Listeners/SyncListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions app/Models/CustomPlaylist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('custom_playlists', function (Blueprint $table) {
$table->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',
]);
});
}
};
Loading
Loading