diff --git a/app/Console/Commands/RescoreChannelFailoversCommand.php b/app/Console/Commands/RescoreChannelFailoversCommand.php new file mode 100644 index 000000000..dde85cf0d --- /dev/null +++ b/app/Console/Commands/RescoreChannelFailoversCommand.php @@ -0,0 +1,66 @@ + 86400, + 'weekly' => 604800, + ]; + + protected $signature = 'app:rescore-channel-failovers {playlist? : Optional playlist ID to rescore directly}'; + + protected $description = 'Dispatch RescoreChannelFailovers for any playlist whose configured interval has elapsed'; + + public function handle(): int + { + $playlistId = $this->argument('playlist'); + + if ($playlistId !== null) { + $playlist = Playlist::find($playlistId); + if (! $playlist) { + $this->error("Playlist {$playlistId} not found"); + + return Command::FAILURE; + } + + dispatch(new RescoreChannelFailovers($playlist->id)); + $this->info("Dispatched failover rescore for playlist {$playlist->id}"); + + return Command::SUCCESS; + } + + $playlists = Playlist::query() + ->whereNotNull('auto_rescore_failovers_interval') + ->get(); + + $dispatched = 0; + foreach ($playlists as $playlist) { + $intervalKey = strtolower((string) $playlist->auto_rescore_failovers_interval); + $intervalSeconds = self::INTERVALS[$intervalKey] ?? null; + if ($intervalSeconds === null) { + continue; + } + + $lastRun = $playlist->last_failover_rescore_at; + if ($lastRun !== null && $lastRun->copy()->addSeconds($intervalSeconds)->gt(now())) { + continue; + } + + dispatch(new RescoreChannelFailovers($playlist->id)); + $dispatched++; + } + + $this->info("Dispatched {$dispatched} playlist(s) for failover rescoring"); + + return Command::SUCCESS; + } +} diff --git a/app/Filament/Pages/M3uProxyStreamMonitor.php b/app/Filament/Pages/M3uProxyStreamMonitor.php index 5065cf936..0e9dabd93 100644 --- a/app/Filament/Pages/M3uProxyStreamMonitor.php +++ b/app/Filament/Pages/M3uProxyStreamMonitor.php @@ -338,11 +338,13 @@ protected function getActiveStreams(): array if (isset($stream['metadata']['type']) && isset($stream['metadata']['id'])) { $modelType = $stream['metadata']['type']; $modelId = $stream['metadata']['id']; + $isSmartChannel = false; if ($modelType === 'channel') { $channel = $channelsById[$modelId] ?? null; if ($channel) { $title = $channel->name_custom ?? $channel->name ?? $channel->title; $logo = LogoFacade::getChannelLogoUrl($channel); + $isSmartChannel = (bool) $channel->is_smart_channel; } } elseif ($modelType === 'episode') { $episode = $episodesById[$modelId] ?? null; @@ -355,6 +357,7 @@ protected function getActiveStreams(): array $model = [ 'title' => $title ?? 'N/A', 'logo' => $logo, + 'is_smart_channel' => $isSmartChannel, ]; } diff --git a/app/Filament/Resources/Channels/ChannelResource.php b/app/Filament/Resources/Channels/ChannelResource.php index aa9949074..622217f23 100644 --- a/app/Filament/Resources/Channels/ChannelResource.php +++ b/app/Filament/Resources/Channels/ChannelResource.php @@ -15,6 +15,7 @@ use App\Jobs\ChannelFindAndReplaceReset; use App\Jobs\MapPlaylistChannelsToEpg; use App\Jobs\ProbeChannelStreams; +use App\Jobs\RescoreChannelFailovers; use App\Jobs\SyncPlexDvrJob; use App\Models\Channel; use App\Models\ChannelFailover; @@ -22,6 +23,7 @@ use App\Models\Group; use App\Models\Playlist; use App\Models\StreamProfile; +use App\Services\Channels\SmartChannelCreator; use App\Services\DateFormatService; use App\Services\EpgCacheService; use App\Services\LogoCacheService; @@ -36,6 +38,7 @@ use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -233,6 +236,13 @@ public static function getTableColumns($showGroup = true, $showPlaylist = true): ->badge() ->toggleable() ->sortable(), + IconColumn::make('is_smart_channel') + ->label(__('Smart')) + ->icon(fn ($record): ?string => $record?->is_smart_channel ? 'heroicon-o-sparkles' : null) + ->color('info') + ->tooltip(fn ($record): ?string => $record?->is_smart_channel ? __('Smart channel — streams the highest-ranked failover automatically') : null) + ->sortable() + ->toggleable(), TextInputColumn::make('stream_id_custom') ->label(__('ID')) ->rules(['min:0', 'max:255']) @@ -1136,12 +1146,23 @@ private static function getBulkActionSchema(bool $addToCustom, bool $includeReco return (int) $record->id !== (int) $masterRecordId; }); + $smartChannelCount = $failoverRecords->filter(fn ($r) => (bool) $r->is_smart_channel)->count(); + $failoverRecords = $failoverRecords->filter(fn ($r) => ! $r->is_smart_channel); + foreach ($failoverRecords as $record) { ChannelFailover::updateOrCreate([ 'channel_id' => $masterRecordId, 'channel_failover_id' => $record->id, ]); } + + if ($smartChannelCount > 0) { + Notification::make() + ->warning() + ->title(__('Some channels were skipped')) + ->body(__(':count smart channel(s) were skipped — smart channels can\'t be used as failovers themselves.', ['count' => $smartChannelCount])) + ->send(); + } })->after(function () { Notification::make() ->success() @@ -1155,6 +1176,110 @@ private static function getBulkActionSchema(bool $addToCustom, bool $includeReco ->modalIcon('heroicon-o-arrow-path-rounded-square') ->modalDescription(__('Add the selected channel(s) to the chosen channel as failover sources.')) ->modalSubmitActionLabel(__('Add failovers now')), + BulkAction::make('make_smart_channel') + ->label(__('Make smart channel')) + ->schema(function (Collection $records) { + $playlists = $records->pluck('playlist_id')->unique(); + $playlistForScoring = $playlists->count() === 1 + ? Playlist::find($playlists->first()) + : null; + + $creator = SmartChannelCreator::fromPlaylist($playlistForScoring); + $ranking = $creator->rank($records); + + $rows = $ranking->map(function ($row, int $index) { + $channel = $row['channel']; + $playlistName = $channel->getEffectivePlaylist()->name ?? 'Unknown'; + $displayTitle = e($channel->title_custom ?: $channel->title ?: $channel->name); + $rankBadge = '#'.($index + 1).''; + $titleCell = "
{$displayTitle}
".e($playlistName).'
'; + $totalScore = ''.number_format($row['score']).''; + + $breakdownParts = []; + foreach ($row['breakdown'] as $attribute => $score) { + $label = e(str_replace('_', ' ', $attribute)); + $breakdownParts[] = "{$label}{$score}"; + } + $breakdownCell = '
'.implode('', $breakdownParts).'
'; + + return "{$rankBadge}{$titleCell}{$totalScore}{$breakdownCell}"; + })->implode(''); + + $headerRow = 'RankChannelScoreBreakdown (per attribute, 0-100)'; + $tableHtml = "{$headerRow}{$rows}
"; + + return [ + Placeholder::make('ranking_preview') + ->label(__('Ranked sources')) + ->content(new HtmlString($tableHtml)) + ->columnSpanFull(), + TextInput::make('title') + ->label(__('Virtual channel title')) + ->helperText(__('Leave empty to copy the highest-scoring source\'s title.')) + ->maxLength(255), + Toggle::make('disable_sources') + ->label(__('Disable source channels')) + ->helperText(__('When enabled, the selected source channels will be disabled after being attached as failovers. They\'ll only be reachable via the new smart channel.')) + ->default(false) + ->inline(false), + ]; + }) + ->action(function (Collection $records, array $data): void { + if ($records->isEmpty()) { + return; + } + + $playlists = $records->pluck('playlist_id')->unique(); + if ($playlists->count() > 1) { + Notification::make() + ->warning() + ->title(__('Mixed playlists not supported')) + ->body(__('Smart channels must be created from sources within a single playlist. Narrow your selection and try again.')) + ->send(); + + return; + } + + if ($records->contains(fn (Channel $channel) => (bool) $channel->is_smart_channel)) { + Notification::make() + ->warning() + ->title(__('Smart channels cannot be sources')) + ->body(__('Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.')) + ->send(); + + return; + } + + $playlistForScoring = Playlist::find($playlists->first()); + + try { + SmartChannelCreator::fromPlaylist($playlistForScoring)->create( + channels: $records, + title: $data['title'] ?? null, + disableSources: (bool) ($data['disable_sources'] ?? false), + ); + } catch (\InvalidArgumentException $e) { + Notification::make() + ->warning() + ->title(__('Could not create smart channel')) + ->body($e->getMessage()) + ->send(); + + return; + } + + Notification::make() + ->success() + ->title(__('Smart channel created')) + ->body(__('A custom channel was created with the selected sources attached as failovers, ranked by quality.')) + ->send(); + }) + ->deselectRecordsAfterCompletion() + ->requiresConfirmation() + ->icon('heroicon-o-arrow-trending-up') + ->modalIcon('heroicon-o-arrow-trending-up') + ->modalDescription(__('Create a custom "smart channel" from the selected channels. The highest-scoring source\'s title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.')) + ->modalSubmitActionLabel(__('Create smart channel')), ]), // -- Probing -- @@ -1315,7 +1440,7 @@ public static function infolist(Schema $schema): Schema ]), Section::make(__('Technical Details')) ->collapsible() - ->visible(fn ($record) => $record && ! $record->is_vod) + ->visible(fn ($record) => $record && ! $record->is_vod && ! $record->isSmartChannel()) ->headerActions([ Action::make('probe') ->label(fn ($record) => match (self::resolveTechnicalDetailsState($record)) { @@ -1491,6 +1616,40 @@ public static function infolist(Schema $schema): Schema ->state(fn () => __("Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.")) ->visible(fn ($record) => self::resolveTechnicalDetailsState($record) === 'disabled'), ]), + Section::make(__('Failover Ranking')) + ->description(__('Failovers ranked by score. Stored from the last merge or rescore — click "Rescore now" to recalculate against current stream stats.')) + ->collapsible() + ->visible(fn ($record) => $record && $record->failovers()->exists()) + ->headerActions([ + Action::make('rescore_failovers_inline') + ->label(__('Rescore now')) + ->icon('heroicon-o-arrow-path') + ->color('info') + ->action(function ($record) { + dispatch(new RescoreChannelFailovers( + playlistId: $record->playlist_id, + channelIds: [$record->id], + )); + + Notification::make() + ->success() + ->title(__('Rescoring queued')) + ->body(__('Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.')) + ->duration(6000) + ->send(); + }) + ->requiresConfirmation() + ->modalIcon('heroicon-o-arrow-path') + ->modalDescription(__('Re-score this channel\'s failovers against current stream stats. Stale channels may be re-probed (subject to the playlist\'s staleness window). The master channel is never altered — only the failover order changes.')) + ->modalSubmitActionLabel(__('Rescore')), + ]) + ->schema([ + TextEntry::make('failover_ranking_html') + ->hiddenLabel() + ->columnSpanFull() + ->state(fn ($record) => self::renderFailoverRankingHtml($record)) + ->html(), + ]), ]); } @@ -1512,6 +1671,139 @@ private static function resolveTechnicalDetailsState(?Channel $record): string return 'ok'; } + /** + * Render the channel's failover ranking as a stack of expandable cards. + * + * Each card's summary row shows rank + title + score; expanding reveals + * the per-attribute breakdown plus the failover's probed technical + * details (resolution, fps, bitrate, codec, audio info, last probed). + * Returns null when the channel has no failovers attached so the section + * can hide itself. + */ + private static function renderFailoverRankingHtml(?Channel $record): ?HtmlString + { + if (! $record) { + return null; + } + + $failovers = $record->failovers() + ->with('channelFailover') + ->orderBy('sort') + ->get(); + + if ($failovers->isEmpty()) { + return null; + } + + $cards = $failovers->map(function ($failover, int $index) { + $channel = $failover->channelFailover; + if (! $channel) { + return ''; + } + + $metadata = $failover->metadata ?? []; + $score = $metadata['score'] ?? null; + $breakdown = $metadata['attribute_scores'] ?? []; + + $displayTitle = e($channel->title_custom ?: $channel->title ?: $channel->name); + $playlistName = e($channel->getEffectivePlaylist()->name ?? 'Unknown'); + + $scoreBadge = $score !== null + ? 'Score '.(int) $score.'' + : 'not scored'; + + $summary = ' + + #'.($index + 1).' +
+
'.$displayTitle.'
+
'.$playlistName.'
+
+ '.$scoreBadge.' +
'; + + $expanded = '
'; + + if (! empty($breakdown)) { + $breakdownParts = []; + foreach ($breakdown as $attribute => $value) { + $label = e(str_replace('_', ' ', $attribute)); + $breakdownParts[] = ''.$label.''.(int) $value.''; + } + $expanded .= '
+
Score breakdown (per attribute, 0-100)
+
'.implode('', $breakdownParts).'
+
'; + } + + $expanded .= self::renderFailoverTechDetails($channel); + $expanded .= '
'; + + return '
'.$summary.$expanded.'
'; + })->implode(''); + + return new HtmlString('
'.$cards.'
'); + } + + /** + * Render a single failover channel's technical details for the expandable + * ranking card. Returns a "not yet probed" hint when no stream stats are + * available so users know whether re-probing might help. + */ + private static function renderFailoverTechDetails(Channel $channel): string + { + if (empty($channel->stream_stats)) { + $hint = $channel->probe_enabled + ? __('Not yet probed — run a probe on this channel to capture stream details.') + : __('Probing is disabled for this channel.'); + + return '
+
Technical details
+
'.e($hint).'
+
'; + } + + $compact = $channel->getStreamStatsForDisplay()['compact'] ?? []; + $rows = []; + + $append = function (string $label, $value, ?string $suffix = null) use (&$rows) { + if ($value === null || $value === '') { + return; + } + $formatted = is_numeric($value) && $suffix === ' kbps' + ? number_format((float) $value, 0).$suffix + : ($suffix ? $value.$suffix : $value); + $rows[] = '
'.e($label).'
'.e((string) $formatted).'
'; + }; + + $append('Resolution', $compact['resolution'] ?? null); + $append('Frame rate', isset($compact['source_fps']) ? $compact['source_fps'] : null, ' fps'); + $append('Video codec', $compact['video_codec_display'] ?? null); + $append('Video bitrate', $compact['ffmpeg_output_bitrate'] ?? null, ' kbps'); + $append('Audio codec', $compact['audio_codec'] ?? null); + $append('Audio channels', $compact['audio_channels'] ?? null); + $append('Audio bitrate', $compact['audio_bitrate'] ?? null, ' kbps'); + $append('Audio language', $compact['audio_language'] ?? null); + + if (empty($rows)) { + return '
+
Technical details
+
'.e(__('Probe ran but returned no usable details.')).'
+
'; + } + + $probedAt = $channel->stream_stats_probed_at?->diffForHumans(); + $footer = $probedAt + ? '
'.e(__('Last probed').': '.$probedAt).'
' + : ''; + + return '
+
Technical details
+
'.implode('', $rows).'
+ '.$footer.' +
'; + } + public static function getForm($customPlaylist = null, $edit = false): array { return [ @@ -1531,6 +1823,12 @@ public static function getForm($customPlaylist = null, $edit = false): array Toggle::make('probe_enabled') ->default(true) ->helperText(__('Allow probing this channel when running playlist channel probe jobs.')), + Toggle::make('is_smart_channel') + ->label(__('Smart channel')) + ->default(false) + ->live() + ->helperText(__('Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.')) + ->visible(fn (Get $get) => (bool) $get('is_custom')), ]), Fieldset::make(__('Playlist Type (choose one)')) ->schema([ @@ -1666,12 +1964,16 @@ public static function getForm($customPlaylist = null, $edit = false): array ->columnSpan(1) ->prefixIcon('heroicon-m-globe-alt') ->hintIcon( - icon: fn (Get $get) => $get('is_custom') ? null : 'heroicon-m-question-mark-circle', - tooltip: fn (Get $get) => $get('is_custom') ? null : 'The original URL from the playlist provider. This is read-only and cannot be modified. This URL is automatically updated on Playlist sync.' + icon: fn (Get $get) => $get('is_smart_channel') + ? 'heroicon-m-sparkles' + : ($get('is_custom') ? null : 'heroicon-m-question-mark-circle'), + tooltip: fn (Get $get) => $get('is_smart_channel') + ? 'This is a smart channel. The streamed URL is taken from the highest-ranked failover automatically — set the URL on a failover channel instead, or remove the smart-channel flag to manage the URL directly.' + : ($get('is_custom') ? null : 'The original URL from the playlist provider. This is read-only and cannot be modified. This URL is automatically updated on Playlist sync.') ) ->formatStateUsing(fn ($record) => $record?->url) - ->disabled(fn (Get $get) => ! $get('is_custom')) // make it read-only but copyable for non-custom channels - ->dehydrated(fn (Get $get) => $get('is_custom')) // don't save the value in the database for custom channels + ->disabled(fn (Get $get) => $get('is_smart_channel') || ! $get('is_custom')) + ->dehydrated(fn (Get $get) => ! $get('is_smart_channel') && $get('is_custom')) ->type('url'), TextInput::make('url_custom') ->label(__('URL Override')) @@ -1830,6 +2132,19 @@ public static function getForm($customPlaylist = null, $edit = false): array ]), Fieldset::make(__('Failover Channels')) ->schema([ + Placeholder::make('smart_channel_no_failovers_warning') + ->hiddenLabel() + ->columnSpanFull() + ->visible(fn (Get $get): bool => (bool) $get('is_smart_channel') && empty($get('failovers'))) + ->content(new HtmlString( + '
+ +
+ '.e(__('Smart channel without failovers won\'t stream.')).' ' + .e(__('Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.')).' +
+
' + )), Repeater::make('failovers') ->relationship() ->label('') @@ -1874,6 +2189,7 @@ public static function getForm($customPlaylist = null, $edit = false): array ->withoutEagerLoads() ->with('playlist') ->whereNotIn('id', $existingFailoverIds) + ->where('is_smart_channel', false) ->where(function ($query) use ($searchLower) { $query->whereRaw('LOWER(title) LIKE ?', ["%{$searchLower}%"]) ->orWhereRaw('LOWER(title_custom) LIKE ?', ["%{$searchLower}%"]) diff --git a/app/Filament/Resources/Playlists/PlaylistResource.php b/app/Filament/Resources/Playlists/PlaylistResource.php index 3d49e9030..08dead653 100644 --- a/app/Filament/Resources/Playlists/PlaylistResource.php +++ b/app/Filament/Resources/Playlists/PlaylistResource.php @@ -19,6 +19,7 @@ use App\Jobs\ProcessM3uImport; use App\Jobs\ProcessM3uImportSeries; use App\Jobs\ProcessVodChannels; +use App\Jobs\RescoreChannelFailovers; use App\Jobs\SyncMediaServer; use App\Livewire\EpgViewer; use App\Livewire\MediaFlowProxyUrl; @@ -615,6 +616,26 @@ public static function getHeaderActions(): array ->send(); }) ->visible(fn ($record) => $record->isProcessing()), + Action::make('rescore_failovers') + ->label(__('Re-score failovers now')) + ->icon('heroicon-o-arrow-trending-up') + ->color('info') + ->action(function ($record) { + app('Illuminate\Contracts\Bus\Dispatcher') + ->dispatch(new RescoreChannelFailovers($record->id)); + })->after(function () { + Notification::make() + ->success() + ->title(__('Failover rescoring queued')) + ->body(__('Failover groups will be re-scored in the background. You can keep using the app while this runs.')) + ->duration(5000) + ->send(); + }) + ->visible(fn ($record): bool => (bool) ($record->auto_merge_channels_enabled ?? false)) + ->requiresConfirmation() + ->modalIcon('heroicon-o-arrow-trending-up') + ->modalDescription(__('Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.')) + ->modalSubmitActionLabel(__('Yes, rescore now')), Action::make('Download M3U') ->label(__('Download M3U')) ->icon('heroicon-o-arrow-down-tray') @@ -2075,6 +2096,8 @@ public static function getFormSections($creating = false, $includeAuth = false): 'group_priority' => '📁 Group Priority (from weights above)', 'catchup_support' => '⏪ Catch-up/Replay Support', 'resolution' => '📺 Resolution (requires stream analysis)', + 'fps' => '🎞️ Frame Rate (requires stream analysis)', + 'bitrate' => '📊 Bitrate (requires stream analysis)', 'codec' => '🎬 Codec Preference (HEVC/H264)', 'keyword_match' => '🏷️ Keyword Match', ]) @@ -2100,6 +2123,30 @@ public static function getFormSections($creating = false, $includeAuth = false): } }), ]), + + ]), + Section::make(__('Failover Rescoring')) + ->description(__('Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel "Rescore now" action, and the "Make smart channel" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.')) + ->columnSpanFull() + ->collapsible() + ->collapsed($creating) + ->columns(2) + ->schema([ + Select::make('auto_rescore_failovers_interval') + ->label(__('Periodic rescoring')) + ->options([ + 'daily' => __('Daily'), + 'weekly' => __('Weekly'), + ]) + ->placeholder(__('Off')) + ->helperText(__('Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the "Rescore now" button.')), + TextInput::make('failover_rescore_staleness_days') + ->label(__('Re-probe channels older than (days)')) + ->numeric() + ->default(7) + ->minValue(0) + ->maxValue(365) + ->helperText(__('During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The "Make smart channel" action uses existing stats only and does not consult this setting.')), ]), Section::make(__('Find & Replace Rules')) ->description(__('Define find & replace rules that automatically run after each playlist sync. Rules execute in order.')) diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php index 0b6b7ead0..2b7590f15 100644 --- a/app/Http/Controllers/PlaylistController.php +++ b/app/Http/Controllers/PlaylistController.php @@ -275,7 +275,7 @@ public function mergeChannels(Request $request, string $uuid): JsonResponse ], 'group_priorities.*.weight' => 'required_with:group_priorities|integer|min:1|max:1000', 'priority_attributes' => 'sometimes|array', - 'priority_attributes.*' => 'string|in:playlist_priority,group_priority,catchup_support,resolution,codec,keyword_match', + 'priority_attributes.*' => 'string|in:playlist_priority,group_priority,catchup_support,resolution,fps,bitrate,codec,keyword_match', ]); $config = $playlist->auto_merge_config ?? []; diff --git a/app/Jobs/MergeChannels.php b/app/Jobs/MergeChannels.php index 53f5c7f82..8d9ade953 100644 --- a/app/Jobs/MergeChannels.php +++ b/app/Jobs/MergeChannels.php @@ -6,6 +6,7 @@ use App\Models\ChannelFailover; use App\Models\Group; use App\Models\Playlist; +use App\Services\Channels\ChannelMergeScorer; use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,16 +20,11 @@ class MergeChannels implements ShouldQueue use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** - * Default priority attributes order (first = highest priority) + * Default priority attributes order (first = highest priority). + * + * @deprecated Use {@see ChannelMergeScorer::DEFAULT_PRIORITY_ORDER}. Kept here for legacy callers. */ - protected const DEFAULT_PRIORITY_ORDER = [ - 'playlist_priority', - 'group_priority', - 'catchup_support', - 'resolution', - 'codec', - 'keyword_match', - ]; + protected const DEFAULT_PRIORITY_ORDER = ChannelMergeScorer::DEFAULT_PRIORITY_ORDER; /** * Cached group priorities for performance @@ -378,43 +374,31 @@ protected function selectMasterByWeightedScore(Collection $group, array $playlis return $topChannels->sortBy('sort')->first(); } + /** + * Build a fresh scorer using this job's weighted config and the supplied playlist priority lookup. + */ + protected function buildScorer(array $playlistPriority): ChannelMergeScorer + { + return new ChannelMergeScorer( + priorityOrder: $this->getPriorityOrder(), + playlistPriority: $playlistPriority, + groupPriorityCache: $this->groupPriorityCache, + preferredCodec: $this->weightedConfig['prefer_codec'] ?? null, + priorityKeywords: $this->weightedConfig['priority_keywords'] ?? [], + ); + } + /** * Calculate weighted score for a channel. */ protected function calculateChannelScore(Channel $channel, array $playlistPriority): int { - $score = 0; - $priorityOrder = $this->getPriorityOrder(); - - // Base multiplier decreases for each priority level - $multiplier = count($priorityOrder) * 1000; - - foreach ($priorityOrder as $attribute) { - $attributeScore = match ($attribute) { - 'playlist_priority' => $this->getPlaylistPriorityScore($channel, $playlistPriority), - 'group_priority' => $this->getGroupPriorityScore($channel), - 'catchup_support' => $this->getCatchupScore($channel), - 'resolution' => $this->getResolutionScore($channel), - 'codec' => $this->getCodecScore($channel), - 'keyword_match' => $this->getKeywordScore($channel), - default => 0, - }; - - $score += $attributeScore * $multiplier; - $multiplier = max(1, $multiplier - 1000); - } - - return $score; + return $this->buildScorer($playlistPriority)->score($channel); } /** * Get normalized priority order from config. * - * Supports both string array format: - * ['playlist_priority', 'resolution'] - * and object array format: - * [['attribute' => 'playlist_priority'], ['attribute' => 'resolution']] - * * @return array */ protected function getPriorityOrder(): array @@ -423,144 +407,13 @@ protected function getPriorityOrder(): array return $this->normalizedPriorityOrder; } - $allowed = array_flip(self::DEFAULT_PRIORITY_ORDER); - $raw = $this->weightedConfig['priority_attributes'] ?? self::DEFAULT_PRIORITY_ORDER; - - if (! is_array($raw) || empty($raw)) { - $this->normalizedPriorityOrder = self::DEFAULT_PRIORITY_ORDER; - - return $this->normalizedPriorityOrder; - } - - $normalized = []; - foreach ($raw as $item) { - $attribute = is_array($item) ? ($item['attribute'] ?? null) : $item; - if (! is_string($attribute)) { - continue; - } - - $attribute = trim($attribute); - if ($attribute === '' || ! isset($allowed[$attribute])) { - continue; - } - - $normalized[] = $attribute; - } - - $normalized = array_values(array_unique($normalized)); - $this->normalizedPriorityOrder = ! empty($normalized) ? $normalized : self::DEFAULT_PRIORITY_ORDER; + $this->normalizedPriorityOrder = ChannelMergeScorer::normalizePriorityOrder( + $this->weightedConfig['priority_attributes'] ?? null + ); return $this->normalizedPriorityOrder; } - /** - * Get playlist priority score (higher = better). - */ - protected function getPlaylistPriorityScore(Channel $channel, array $playlistPriority): int - { - // Invert priority so lower index = higher score - $priority = $playlistPriority[$channel->playlist_id] ?? 999; - - return max(0, 100 - $priority); - } - - /** - * Get group priority score from config (higher = better). - */ - protected function getGroupPriorityScore(Channel $channel): int - { - return $this->groupPriorityCache[$channel->group_id] ?? 0; - } - - /** - * Get catchup support score. - */ - protected function getCatchupScore(Channel $channel): int - { - return ! empty($channel->catchup) ? 100 : 0; - } - - /** - * Get resolution score (normalized 0-100). - */ - protected function getResolutionScore(Channel $channel): int - { - $resolution = $this->getResolution($channel); - - // Normalize: 4K (3840x2160 = 8294400) = 100, 1080p = ~25, 720p = ~11 - return min(100, (int) ($resolution / 82944)); - } - - /** - * Get codec preference score. - */ - protected function getCodecScore(Channel $channel): int - { - $preferredCodec = $this->weightedConfig['prefer_codec'] ?? null; - if (! $preferredCodec) { - return 0; - } - - $channelCodec = $this->getCodec($channel); - if (! $channelCodec) { - return 0; - } - - $preferredCodec = strtolower($preferredCodec); - $channelCodec = strtolower($channelCodec); - - $isHevc = str_contains($channelCodec, 'hevc') || str_contains($channelCodec, 'h265') || str_contains($channelCodec, '265'); - $isH264 = str_contains($channelCodec, 'h264') || str_contains($channelCodec, 'avc') || str_contains($channelCodec, '264'); - - if ($preferredCodec === 'hevc' || $preferredCodec === 'h265') { - return $isHevc ? 100 : 0; - } - - if ($preferredCodec === 'h264' || $preferredCodec === 'avc') { - return $isH264 ? 100 : 0; - } - - return 0; - } - - /** - * Get keyword match score. - */ - protected function getKeywordScore(Channel $channel): int - { - $keywords = $this->weightedConfig['priority_keywords'] ?? []; - if (empty($keywords)) { - return 0; - } - - $channelName = strtolower($channel->title ?? $channel->name ?? ''); - $matchCount = 0; - - foreach ($keywords as $keyword) { - if (str_contains($channelName, strtolower($keyword))) { - $matchCount++; - } - } - - // More matches = higher score (cap at 100) - return min(100, $matchCount * 25); - } - - /** - * Get codec from channel stream stats. - */ - protected function getCodec(Channel $channel): ?string - { - $streamStats = $channel->ensureStreamStats(); - foreach ($streamStats as $stream) { - if (isset($stream['stream']['codec_type']) && $stream['stream']['codec_type'] === 'video') { - return $stream['stream']['codec_name'] ?? null; - } - } - - return null; - } - /** * Sort channels by score (descending). */ @@ -577,7 +430,7 @@ protected function sortChannelsByScore(Collection $channels, array $playlistPrio if ($this->checkResolution) { return $channels->sortBy(fn ($channel) => [ $this->preferCatchupAsPrimary && empty($channel->catchup) ? 1 : 0, - -(int) $this->getResolution($channel), + -(int) ChannelMergeScorer::getResolution($channel), (int) ($playlistPriority[$channel->playlist_id] ?? 999), $channel->sort ?? 999999, ]); @@ -605,7 +458,7 @@ protected function selectMasterLegacy(Collection $group, array $playlistPriority $channelsWithResolution = $selectionGroup->map(function ($channel) { return [ 'channel' => $channel, - 'resolution' => $this->getResolution($channel), + 'resolution' => ChannelMergeScorer::getResolution($channel), ]; }); @@ -635,21 +488,6 @@ protected function selectMasterLegacy(Collection $group, array $playlistPriority ])->first(); } - /** - * Get resolution from channel stream stats. - */ - protected function getResolution(Channel $channel): int - { - $streamStats = $channel->ensureStreamStats(); - foreach ($streamStats as $stream) { - if (isset($stream['stream']['codec_type']) && $stream['stream']['codec_type'] === 'video') { - return ($stream['stream']['width'] ?? 0) * ($stream['stream']['height'] ?? 0); - } - } - - return 0; - } - protected function sendCompletionNotification(int $processed, int $deactivatedCount = 0): void { if ($processed > 0) { diff --git a/app/Jobs/RescoreChannelFailovers.php b/app/Jobs/RescoreChannelFailovers.php new file mode 100644 index 000000000..449af3087 --- /dev/null +++ b/app/Jobs/RescoreChannelFailovers.php @@ -0,0 +1,214 @@ +|null $channelIds Optional master-channel filter (manual scoped runs) + */ + public function __construct( + public int $playlistId, + public ?array $channelIds = null, + ) {} + + /** + * Prevent concurrent runs against the same playlist. If a job is already in + * flight, additional dispatches (manual + scheduled, or two manual clicks) + * are dropped — scoring is idempotent and re-probing the same upstream URLs + * twice would just waste provider quota. Lock auto-releases at the job + * timeout so a crashed worker doesn't hold it indefinitely. + * + * @return array + */ + public function middleware(): array + { + return [ + (new WithoutOverlapping((string) $this->playlistId)) + ->releaseAfter($this->timeout) + ->expireAfter($this->timeout), + ]; + } + + public function handle(): void + { + $playlist = Playlist::find($this->playlistId); + if (! $playlist) { + Log::warning("RescoreChannelFailovers: playlist {$this->playlistId} not found"); + + return; + } + + $masterIds = ChannelFailover::query() + ->whereHas('channel', fn ($q) => $q->where('playlist_id', $playlist->id)) + ->when($this->channelIds, fn ($q, $ids) => $q->whereIn('channel_id', $ids)) + ->distinct() + ->pluck('channel_id'); + + if ($masterIds->isEmpty()) { + $playlist->update(['last_failover_rescore_at' => now()]); + + return; + } + + $stalenessDays = (int) ($playlist->failover_rescore_staleness_days ?? 7); + $staleBefore = $stalenessDays > 0 ? Carbon::now()->subDays($stalenessDays) : null; + $probeTimeout = (int) ($playlist->probe_timeout ?? 15); + + $scorer = $this->buildScorer($playlist); + + // Eager-load masters with their failover channels in one query so the + // per-iteration loop doesn't N+1. + $masters = Channel::query() + ->with('failoverChannels') + ->whereIn('id', $masterIds) + ->get(); + + foreach ($masters as $master) { + $failovers = $master->failoverChannels; + if ($failovers->isEmpty()) { + continue; + } + + $this->ensureFreshStats($master, $staleBefore, $probeTimeout); + foreach ($failovers as $failover) { + $this->ensureFreshStats($failover, $staleBefore, $probeTimeout); + } + + $this->reorderFailovers($master, $failovers, $scorer); + } + + $playlist->update(['last_failover_rescore_at' => now()]); + } + + /** + * Build a scorer using the playlist's auto_merge_config, falling back to + * a sensible default of [resolution, fps, bitrate, codec] when no config + * is set. + */ + protected function buildScorer(Playlist $playlist): ChannelMergeScorer + { + $config = $playlist->auto_merge_config ?? []; + + $rawAttributes = $config['priority_attributes'] ?? null; + $priorityOrder = empty($rawAttributes) + ? ['resolution', 'fps', 'bitrate', 'codec'] + : ChannelMergeScorer::normalizePriorityOrder($rawAttributes); + + $groupPriorityCache = []; + foreach ($config['group_priorities'] ?? [] as $group) { + if (isset($group['group_id'], $group['weight'])) { + $groupPriorityCache[(int) $group['group_id']] = (int) $group['weight']; + } + } + + return new ChannelMergeScorer( + priorityOrder: $priorityOrder, + playlistPriority: [$playlist->id => 0], + groupPriorityCache: $groupPriorityCache, + preferredCodec: $config['prefer_codec'] ?? null, + priorityKeywords: $config['priority_keywords'] ?? [], + ); + } + + /** + * Re-probe a channel if its stats are missing or older than the staleness window. + */ + protected function ensureFreshStats(Channel $channel, ?CarbonInterface $staleBefore, int $probeTimeout): void + { + if (! $channel->probe_enabled) { + return; + } + + $needsReprobe = $channel->stream_stats_probed_at === null + || ($staleBefore !== null && $channel->stream_stats_probed_at->lt($staleBefore)); + + if (! $needsReprobe) { + return; + } + + try { + $stats = $this->withProviderThrottling( + fn () => $channel->probeStreamStats($probeTimeout) + ); + + if (! empty($stats)) { + $channel->updateQuietly([ + 'stream_stats' => $stats, + 'stream_stats_probed_at' => now(), + ]); + } + } catch (Throwable $e) { + Log::warning("RescoreChannelFailovers: probe failed for channel {$channel->id}: {$e->getMessage()}"); + } + } + + /** + * Score every failover and update channel_failovers.sort so the best + * failover sits at sort=0. The master is intentionally not scored or altered. + * + * Each failover's score and per-attribute breakdown is persisted into + * channel_failovers.metadata so the rationale stays inspectable later. + * + * @param Collection $failovers + */ + protected function reorderFailovers(Channel $master, $failovers, ChannelMergeScorer $scorer): void + { + $scored = $failovers->map(fn (Channel $failover) => [ + 'failover_id' => $failover->id, + 'score' => $scorer->score($failover), + 'breakdown' => $scorer->scoreBreakdown($failover), + ])->sortByDesc('score')->values(); + + $rankedAt = now()->toIso8601String(); + foreach ($scored as $index => $row) { + ChannelFailover::query() + ->where('channel_id', $master->id) + ->where('channel_failover_id', $row['failover_id']) + ->update([ + 'sort' => $index, + 'metadata' => [ + 'score' => $row['score'], + 'attribute_scores' => $row['breakdown'], + 'priority_order' => array_keys($row['breakdown']), + 'ranked_at' => $rankedAt, + ], + ]); + } + } +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php index ab218088b..4383e7eb6 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -50,6 +50,7 @@ class Channel extends Model 'extvlcopt' => 'array', 'kodidrop' => 'array', 'is_custom' => 'boolean', + 'is_smart_channel' => 'boolean', 'is_vod' => 'boolean', 'enable_proxy' => 'boolean', 'tmdb_id' => 'integer', @@ -378,15 +379,16 @@ public function ensureStreamStats(): array /** * Run ffprobe against this channel's stream URL and return parsed stats. * - * Returns a flat list of entries, each with one of two shapes: - * - Stream entry: ['stream' => ['codec_type' => string, 'codec_name' => string, ...]] - * - Format entry: ['format' => ['bit_rate' => string]] (appended once when available) + * Returns a list of entries. Per-stream entries are keyed under `stream` and + * carry a `codec_type`. A trailing entry keyed under `format` carries + * container-level metadata (bit_rate, duration, format_name). * - * The format entry carries the container-level bit_rate from `-show_format`. It is used - * as a fallback video bitrate for live MPEG-TS streams where ffprobe cannot determine - * a per-stream bit_rate. See getEmbyStreamStats() for the derivation logic. + * For live MPEG-TS / HLS streams, neither the per-stream video `bit_rate` + * nor the format-level `bit_rate` is typically populated (no known + * duration). In that case a short packet-sampling probe measures the actual + * throughput and back-fills the video stream's `bit_rate` field. * - * @return list}}|array{format: array{bit_rate: string}}> + * @return array, format?: array{bit_rate: ?string, duration: ?string, format_name: ?string}}> */ public function probeStreamStats(int $timeout = 15): array { @@ -410,8 +412,12 @@ public function probeStreamStats(int $timeout = 15): array $json = json_decode($output, true); if (isset($json['streams']) && is_array($json['streams'])) { $streamStats = []; + $videoBitRateMissing = false; foreach ($json['streams'] as $stream) { if (isset($stream['codec_name'])) { + if (($stream['codec_type'] ?? '') === 'video' && empty($stream['bit_rate'])) { + $videoBitRateMissing = true; + } $streamStats[]['stream'] = [ 'codec_type' => $stream['codec_type'], 'codec_name' => $stream['codec_name'], @@ -433,13 +439,27 @@ public function probeStreamStats(int $timeout = 15): array } } - // MPEG-TS live streams typically don't expose a per-stream video - // bit_rate (no CBR container, unknown duration). Capture the - // container-level bit_rate from -show_format so we can derive a - // sensible video bitrate fallback in getEmbyStreamStats(). - $formatBitRate = $json['format']['bit_rate'] ?? null; - if ($formatBitRate !== null) { - $streamStats[] = ['format' => ['bit_rate' => $formatBitRate]]; + if ($videoBitRateMissing) { + $sampledBps = $this->sampleVideoBitrate($url, $timeout); + if ($sampledBps !== null) { + foreach ($streamStats as &$entry) { + if (isset($entry['stream']) && ($entry['stream']['codec_type'] ?? '') === 'video') { + $entry['stream']['bit_rate'] = (string) $sampledBps; + break; + } + } + unset($entry); + } + } + + if (isset($json['format']) && is_array($json['format'])) { + $streamStats[] = [ + 'format' => [ + 'bit_rate' => $json['format']['bit_rate'] ?? null, + 'duration' => $json['format']['duration'] ?? null, + 'format_name' => $json['format']['format_name'] ?? null, + ], + ]; } return $streamStats; @@ -451,6 +471,79 @@ public function probeStreamStats(int $timeout = 15): array return []; } + /** + * Measure video bitrate by sampling packets from the live stream. + * + * Used when ffprobe's metadata pass leaves both per-stream and format-level + * `bit_rate` null (common for live MPEG-TS / HLS). Reads ~5 seconds of + * video packets and computes (bytes * 8 / duration). Returns bps as an + * integer, or null if no usable packets were captured. + * + * Caps its timeout at 10 seconds regardless of the metadata-pass timeout — + * `-read_intervals "%+5"` only reads 5 seconds of stream, so longer is + * pointless and would compound with the metadata-pass budget for jobs + * that probe many channels. + */ + protected function sampleVideoBitrate(string $url, int $timeout): ?int + { + try { + $process = new SymfonyProcess([ + 'ffprobe', + '-v', 'quiet', + '-print_format', 'json', + '-read_intervals', '%+5', + '-select_streams', 'v:0', + '-show_entries', 'packet=size,pts_time', + $url, + ]); + $process->setTimeout(min($timeout, 10)); + $process->run(); + + if ($process->getExitCode() !== 0) { + return null; + } + + $json = json_decode($process->getOutput(), true); + + return self::computeBitrateFromPackets($json['packets'] ?? []); + } catch (Exception $e) { + Log::error("Error sampling video bitrate for channel \"{$this->title}\": {$e->getMessage()}"); + + return null; + } + } + + /** + * Compute bitrate (bps) from a sequence of ffprobe `-show_packets` entries. + * + * Returns null when there are too few packets, the time span is non-positive, + * or the total byte count is zero — i.e. when no meaningful measurement can + * be derived. + * + * @param array $packets + */ + public static function computeBitrateFromPackets(array $packets): ?int + { + if (count($packets) < 2) { + return null; + } + + $totalBytes = 0; + foreach ($packets as $packet) { + $totalBytes += (int) ($packet['size'] ?? 0); + } + + $firstPts = (float) ($packets[0]['pts_time'] ?? 0); + $lastPts = (float) ($packets[count($packets) - 1]['pts_time'] ?? 0); + $duration = $lastPts - $firstPts; + + if ($duration <= 0 || $totalBytes <= 0) { + return null; + } + + return (int) round($totalBytes * 8 / $duration); + } + /** * Build stream_stats in the format expected by emby-xtream (Dispatcharr-compatible). * @@ -465,10 +558,10 @@ public function getEmbyStreamStats(): array $video = null; $audio = null; - $formatBitRate = null; + $format = null; foreach ($stats as $entry) { - if (isset($entry['format']['bit_rate'])) { - $formatBitRate = $entry['format']['bit_rate']; + if (isset($entry['format']) && is_array($entry['format'])) { + $format = $entry['format']; continue; } @@ -506,20 +599,16 @@ public function getEmbyStreamStats(): array $result['source_fps'] = $fps ? (float) $fps : null; } - // Convert bps to kbps. For MPEG-TS live streams ffprobe usually - // reports no per-stream bit_rate on the video elementary stream - // (no CBR container, unknown duration). Fall back to - // container_bitrate - audio_bitrate, which is a tight upper bound - // for the video bitrate on a typical 1 video + 1 audio TS mux. + // Per-stream video bit_rate is often null for MPEG-TS / HLS containers. + // Fall back to (format.bit_rate − audio.bit_rate). Includes muxing overhead + // (~3-5% inflation) but is good enough for ranking and Technical Details display. // NOTE: only the first audio track's bitrate is subtracted, so streams // with multiple audio tracks will produce a slightly overstated value. $bitRate = $video['bit_rate'] ?? null; - if ($bitRate === null && $formatBitRate !== null) { + if ($bitRate === null && $format !== null && isset($format['bit_rate'])) { $audioBps = isset($audio['bit_rate']) ? (float) $audio['bit_rate'] : 0.0; - $derived = (float) $formatBitRate - $audioBps; - if ($derived > 0) { - $bitRate = $derived; - } + $videoBps = max(0.0, (float) $format['bit_rate'] - $audioBps); + $bitRate = $videoBps > 0 ? $videoBps : null; } $result['ffmpeg_output_bitrate'] = $bitRate ? round((float) $bitRate / 1000, 1) : null; } @@ -587,6 +676,9 @@ public function getStreamStatsForDisplay(): array $allStreams = []; foreach ($stats as $index => $entry) { + if (isset($entry['format']) && ! isset($entry['stream'])) { + continue; + } $stream = $entry['stream'] ?? $entry; $type = $stream['codec_type'] ?? null; @@ -758,6 +850,29 @@ public function hasMovieId(): bool return $this->getTmdbId() !== null || $this->getImdbId() !== null; } + /** + * Convenience helper: same as reading $this->is_smart_channel, but cast-safe + * for code that wants a boolean expression in templates / closures. + */ + public function isSmartChannel(): bool + { + return (bool) $this->is_smart_channel; + } + + /** + * Filter to only channels that have been classified as smart channels. + * + * Smart channels are custom wrappers with no URL of their own; the + * effective stream URL comes from the highest-ranked attached failover + * via PlaylistUrlService::getChannelUrl(). The flag is set explicitly + * (e.g. by SmartChannelCreator) — composite "looks like one" configs + * don't auto-classify. + */ + public function scopeSmartChannels(Builder $query): Builder + { + return $query->where('is_smart_channel', true); + } + public function scopeHasMovieId(Builder $query): Builder { $isPgsql = config('database.connections.'.config('database.default').'.driver') === 'pgsql'; diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 7bb4fd863..c8e88aad8 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -79,6 +79,8 @@ class Playlist extends Model 'enable_series' => 'boolean', 'auto_retry_503_count' => 'integer', 'auto_retry_503_last_at' => 'datetime', + 'last_failover_rescore_at' => 'datetime', + 'failover_rescore_staleness_days' => 'integer', ]; public function getFolderPathAttribute(): string diff --git a/app/Services/Channels/ChannelMergeScorer.php b/app/Services/Channels/ChannelMergeScorer.php new file mode 100644 index 000000000..e41ef45cc --- /dev/null +++ b/app/Services/Channels/ChannelMergeScorer.php @@ -0,0 +1,298 @@ + $priorityOrder Normalized priority order, in priority-descending order + * @param array $playlistPriority Map of playlist_id => priority index (lower index = higher priority) + * @param array $groupPriorityCache Map of group_id => weight + * @param array $priorityKeywords Substrings to match against channel title + */ + public function __construct( + protected array $priorityOrder, + protected array $playlistPriority = [], + protected array $groupPriorityCache = [], + protected ?string $preferredCodec = null, + protected array $priorityKeywords = [], + ) {} + + /** + * Normalize a user-supplied priority attributes config. + * + * Accepts either a flat string array (`['resolution', 'fps']`) or the + * Filament repeater shape (`[['attribute' => 'resolution'], ...]`). + * Filters out anything not in DEFAULT_PRIORITY_ORDER. Returns the default + * order when the input is empty or fully invalid. + * + * @param array|null $raw + * @return array + */ + public static function normalizePriorityOrder(?array $raw): array + { + if (! is_array($raw) || empty($raw)) { + return self::DEFAULT_PRIORITY_ORDER; + } + + $allowed = array_flip(self::DEFAULT_PRIORITY_ORDER); + $normalized = []; + + foreach ($raw as $item) { + $attribute = is_array($item) ? ($item['attribute'] ?? null) : $item; + if (! is_string($attribute)) { + continue; + } + + $attribute = trim($attribute); + if ($attribute === '' || ! isset($allowed[$attribute])) { + continue; + } + + $normalized[] = $attribute; + } + + $normalized = array_values(array_unique($normalized)); + + return ! empty($normalized) ? $normalized : self::DEFAULT_PRIORITY_ORDER; + } + + /** + * Calculate the weighted score for a channel under the configured priority order. + * + * Each attribute scores 0-100 (see scoreBreakdown). Weights are positional: + * with N priorities the first attribute is weighted ×N, the second ×(N-1), + * down to ×1. The total is normalized so the final score is always 0-100, + * regardless of how many priority attributes are configured. + * + * score = round( 100 × Σ(rawᵢ × weightᵢ) / (100 × Σ weightᵢ) ) + * = round( Σ(rawᵢ × weightᵢ) / Σ weightᵢ ) + * + * Relative ordering is identical to the unnormalized weighted sum, so this + * is a pure UX simplification — no behavioral change for ranking. + */ + public function score(Channel $channel): int + { + $count = count($this->priorityOrder); + if ($count === 0) { + return 0; + } + + $sumOfWeights = $count * ($count + 1) / 2; + $weighted = 0; + $weight = $count; + + foreach ($this->priorityOrder as $attribute) { + $weighted += $this->attributeScore($attribute, $channel) * $weight; + $weight--; + } + + return (int) round($weighted / $sumOfWeights); + } + + /** + * Return per-attribute scores (0-100 each) for a channel under the configured priority order. + * + * Useful for explaining why a channel ranked the way it did. The priority + * order is preserved so the highest-impact attributes appear first. + * + * @return array + */ + public function scoreBreakdown(Channel $channel): array + { + $breakdown = []; + foreach ($this->priorityOrder as $attribute) { + $breakdown[$attribute] = $this->attributeScore($attribute, $channel); + } + + return $breakdown; + } + + /** + * Score a single attribute (0-100). Returns 0 for unknown attributes. + */ + protected function attributeScore(string $attribute, Channel $channel): int + { + return match ($attribute) { + 'playlist_priority' => $this->getPlaylistPriorityScore($channel), + 'group_priority' => $this->getGroupPriorityScore($channel), + 'catchup_support' => $this->getCatchupScore($channel), + 'resolution' => $this->getResolutionScore($channel), + 'fps' => $this->getFpsScore($channel), + 'bitrate' => $this->getBitrateScore($channel), + 'codec' => $this->getCodecScore($channel), + 'keyword_match' => $this->getKeywordScore($channel), + default => 0, + }; + } + + protected function getPlaylistPriorityScore(Channel $channel): int + { + $priority = $this->playlistPriority[$channel->playlist_id] ?? 999; + + return max(0, 100 - $priority); + } + + protected function getGroupPriorityScore(Channel $channel): int + { + return $this->groupPriorityCache[$channel->group_id] ?? 0; + } + + protected function getCatchupScore(Channel $channel): int + { + return ! empty($channel->catchup) ? 100 : 0; + } + + protected function getResolutionScore(Channel $channel): int + { + $resolution = self::getResolution($channel); + + // Normalize: 4K (3840x2160 = 8294400) = 100, 1080p = ~25, 720p = ~11 + return min(100, (int) ($resolution / 82944)); + } + + protected function getFpsScore(Channel $channel): int + { + $fps = self::getFps($channel); + + // Normalize: 25/30 fps = ~25-30, 50/60 fps = ~50-60, 100+ fps caps at 100 + return min(100, (int) round($fps)); + } + + protected function getBitrateScore(Channel $channel): int + { + $kbps = self::getBitrate($channel); + + // Normalize: 5000 kbps = 50, 10000+ kbps caps at 100 + return min(100, (int) ($kbps / 100)); + } + + protected function getCodecScore(Channel $channel): int + { + if (! $this->preferredCodec) { + return 0; + } + + $channelCodec = self::getCodec($channel); + if (! $channelCodec) { + return 0; + } + + $preferred = strtolower($this->preferredCodec); + $codec = strtolower($channelCodec); + + $isHevc = str_contains($codec, 'hevc') || str_contains($codec, 'h265') || str_contains($codec, '265'); + $isH264 = str_contains($codec, 'h264') || str_contains($codec, 'avc') || str_contains($codec, '264'); + + if ($preferred === 'hevc' || $preferred === 'h265') { + return $isHevc ? 100 : 0; + } + + if ($preferred === 'h264' || $preferred === 'avc') { + return $isH264 ? 100 : 0; + } + + return 0; + } + + protected function getKeywordScore(Channel $channel): int + { + if (empty($this->priorityKeywords)) { + return 0; + } + + $channelName = strtolower($channel->title ?? $channel->name ?? ''); + $matchCount = 0; + + foreach ($this->priorityKeywords as $keyword) { + if (str_contains($channelName, strtolower($keyword))) { + $matchCount++; + } + } + + return min(100, $matchCount * 25); + } + + /** + * Total pixel count of the first video stream (width * height). + */ + public static function getResolution(Channel $channel): int + { + $streamStats = $channel->ensureStreamStats(); + foreach ($streamStats as $entry) { + $stream = $entry['stream'] ?? null; + if (is_array($stream) && ($stream['codec_type'] ?? null) === 'video') { + return (int) ($stream['width'] ?? 0) * (int) ($stream['height'] ?? 0); + } + } + + return 0; + } + + /** + * Frame rate (fps) of the first video stream. Routes via getEmbyStreamStats + * so the fractional-rate parsing (e.g. "30000/1001" → 29.97) is reused. + */ + public static function getFps(Channel $channel): float + { + $channel->ensureStreamStats(); + $emby = $channel->getEmbyStreamStats(); + + return (float) ($emby['source_fps'] ?? 0.0); + } + + /** + * Video bitrate (kbps). Routes via getEmbyStreamStats so format-level and + * packet-sampling fallbacks for live MPEG-TS streams apply. + */ + public static function getBitrate(Channel $channel): int + { + $channel->ensureStreamStats(); + $emby = $channel->getEmbyStreamStats(); + + return (int) ($emby['ffmpeg_output_bitrate'] ?? 0); + } + + /** + * Codec name of the first video stream, or null if no video stream is present. + */ + public static function getCodec(Channel $channel): ?string + { + $streamStats = $channel->ensureStreamStats(); + foreach ($streamStats as $entry) { + $stream = $entry['stream'] ?? null; + if (is_array($stream) && ($stream['codec_type'] ?? null) === 'video') { + return $stream['codec_name'] ?? null; + } + } + + return null; + } +} diff --git a/app/Services/Channels/SmartChannelCreator.php b/app/Services/Channels/SmartChannelCreator.php new file mode 100644 index 000000000..29e8e65c2 --- /dev/null +++ b/app/Services/Channels/SmartChannelCreator.php @@ -0,0 +1,152 @@ + $channels + */ + public function create(Collection $channels, ?string $title = null, bool $disableSources = false): Channel + { + if ($channels->isEmpty()) { + throw new \InvalidArgumentException('Cannot build a smart channel from an empty selection.'); + } + + if ($channels->contains(fn (Channel $channel) => (bool) $channel->is_smart_channel)) { + throw new \InvalidArgumentException('Smart channels cannot be used as sources for another smart channel — pick raw provider channels instead.'); + } + + if ($channels->pluck('playlist_id')->unique()->count() > 1) { + throw new \InvalidArgumentException('All sources for a smart channel must belong to the same playlist.'); + } + + $ranking = $this->rank($channels); + + /** @var Channel $top */ + $top = $ranking->first()['channel']; + + $resolvedTitle = $title !== null && $title !== '' + ? $title + : ($top->title_custom ?: $top->title ?: $top->name); + + $smartChannel = Channel::create([ + 'user_id' => $top->user_id, + 'playlist_id' => $top->playlist_id, + 'group_id' => $top->group_id, + 'group' => $top->group, + 'is_custom' => true, + 'is_smart_channel' => true, + 'enabled' => true, + 'url' => null, + 'title' => $resolvedTitle, + 'name' => $resolvedTitle, + 'logo' => $top->logo, + 'logo_internal' => $top->logo_internal ?? $top->logo, + 'epg_channel_id' => $top->epg_channel_id, + 'channel' => $top->channel, + 'shift' => $top->shift ?? 0, + 'is_vod' => false, + ]); + + $rankedAt = now()->toIso8601String(); + foreach ($ranking as $index => $row) { + ChannelFailover::create([ + 'user_id' => $smartChannel->user_id, + 'channel_id' => $smartChannel->id, + 'channel_failover_id' => $row['channel']->id, + 'sort' => $index, + 'metadata' => [ + 'score' => $row['score'], + 'attribute_scores' => $row['breakdown'], + 'priority_order' => array_keys($row['breakdown']), + 'ranked_at' => $rankedAt, + ], + ]); + } + + if ($disableSources) { + Channel::whereIn('id', $channels->pluck('id')->all())->update(['enabled' => false]); + } + + return $smartChannel->fresh(); + } + + /** + * Score and rank the supplied channels, returning each with its score and + * per-attribute breakdown. Same logic as create() uses internally — exposed + * so callers (e.g. bulk-action modals) can preview the ranking before + * committing to creating the smart channel. + * + * @param Collection $channels + * @return Collection}> + */ + public function rank(Collection $channels): Collection + { + return $channels + ->map(fn (Channel $channel) => [ + 'channel' => $channel, + 'score' => $this->scorer->score($channel), + 'breakdown' => $this->scorer->scoreBreakdown($channel), + ]) + ->sortByDesc('score') + ->values(); + } + + /** + * Build a creator using a playlist's auto_merge_config (or a sensible + * default of [resolution, fps, bitrate, codec] when no config is set). + */ + public static function fromPlaylist(?Playlist $playlist): self + { + $config = $playlist?->auto_merge_config ?? []; + + $rawAttributes = $config['priority_attributes'] ?? null; + $priorityOrder = empty($rawAttributes) + ? ['resolution', 'fps', 'bitrate', 'codec'] + : ChannelMergeScorer::normalizePriorityOrder($rawAttributes); + + $groupPriorityCache = []; + foreach ($config['group_priorities'] ?? [] as $group) { + if (isset($group['group_id'], $group['weight'])) { + $groupPriorityCache[(int) $group['group_id']] = (int) $group['weight']; + } + } + + return new self( + new ChannelMergeScorer( + priorityOrder: $priorityOrder, + playlistPriority: $playlist ? [$playlist->id => 0] : [], + groupPriorityCache: $groupPriorityCache, + preferredCodec: $config['prefer_codec'] ?? null, + priorityKeywords: $config['priority_keywords'] ?? [], + ) + ); + } +} diff --git a/app/Services/PlaylistService.php b/app/Services/PlaylistService.php index a9802da75..da927a4f5 100644 --- a/app/Services/PlaylistService.php +++ b/app/Services/PlaylistService.php @@ -1034,6 +1034,8 @@ public static function getMergeFormSchema(): array 'group_priority' => '📁 Group Priority (from weights above)', 'catchup_support' => '⏪ Catch-up/Replay Support', 'resolution' => '📺 Resolution (requires stream analysis)', + 'fps' => '🎞️ Frame Rate (requires stream analysis)', + 'bitrate' => '📊 Bitrate (requires stream analysis)', 'codec' => '🎬 Codec Preference (HEVC/H264)', 'keyword_match' => '🏷️ Keyword Match', ]) diff --git a/database/migrations/2026_04_26_221703_add_failover_rescore_settings_to_playlists_table.php b/database/migrations/2026_04_26_221703_add_failover_rescore_settings_to_playlists_table.php new file mode 100644 index 000000000..1d2f09772 --- /dev/null +++ b/database/migrations/2026_04_26_221703_add_failover_rescore_settings_to_playlists_table.php @@ -0,0 +1,34 @@ +string('auto_rescore_failovers_interval')->nullable()->after('probe_timeout'); + $table->timestamp('last_failover_rescore_at')->nullable()->after('auto_rescore_failovers_interval'); + $table->unsignedSmallInteger('failover_rescore_staleness_days')->default(7)->after('last_failover_rescore_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('playlists', function (Blueprint $table) { + $table->dropColumn([ + 'auto_rescore_failovers_interval', + 'last_failover_rescore_at', + 'failover_rescore_staleness_days', + ]); + }); + } +}; diff --git a/database/migrations/2026_04_26_233628_add_is_smart_channel_to_channels_table.php b/database/migrations/2026_04_26_233628_add_is_smart_channel_to_channels_table.php new file mode 100644 index 000000000..6bd859198 --- /dev/null +++ b/database/migrations/2026_04_26_233628_add_is_smart_channel_to_channels_table.php @@ -0,0 +1,44 @@ +boolean('is_smart_channel')->default(false)->after('is_custom'); + $table->index('is_smart_channel'); + }); + + // Backfill: any existing custom channel with no URL of its own that + // already has at least one failover attached is, in effect, a smart + // channel — flip the flag so the UI/scoping treats it as one. + DB::table('channels') + ->where('is_custom', true) + ->whereNull('url') + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('channel_failovers') + ->whereColumn('channel_failovers.channel_id', 'channels.id'); + }) + ->update(['is_smart_channel' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('channels', function (Blueprint $table) { + $table->dropIndex(['is_smart_channel']); + $table->dropColumn('is_smart_channel'); + }); + } +}; diff --git a/lang/de.json b/lang/de.json index f56ec2747..72e87d987 100644 --- a/lang/de.json +++ b/lang/de.json @@ -16,17 +16,29 @@ "01:30:00": "01:30:00", "1": "1", "1-1000, higher = more preferred": "1-1000, höher = bevorzugt", - "10": "10", "10 based rating of the VOD content.": "10-basierte Bewertung des VOD-Inhalts.", - "2": "2", - "3": "3", - "4": "4", - "5": "5", - "6": "6", - "7": "7", - "8": "8", + "2": "10", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "7": "6", + "8": "7", "8.7": "8.7", - "9": "9", + "9": "8", + "10": "9", + "11": "0", + "12": "1", + "13": "10", + "14": "2", + "15": "3", + "16": "4", + "17": "5", + "18": "6", + "19": "7", + "20": "8", + "21": "9", + ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.": ":count Smart Channel(s) wurden übersprungen – Smart Channels können selbst nicht als Failover verwendet werden.", ":images images": ":images Bilder", ":name installed": ":name installiert", ":name updated": ":name aktualisiert", @@ -35,6 +47,7 @@ ":uploaded uploaded · :cached cached": ":uploaded hochgeladen · :cached zwischengespeichert", "A SHA-256 checksum is required to stage this update.": "Für die Bereitstellung dieses Updates ist eine SHA-256-Prüfsumme erforderlich.", "A channel dedicated to classic movies from the golden age of cinema": "Ein Kanal, der klassischen Filmen aus dem goldenen Zeitalter des Kinos gewidmet ist", + "A custom channel was created with the selected sources attached as failovers, ranked by quality.": "Es wurde ein benutzerdefinierter Kanal erstellt, an den die ausgewählten Quellen als Failover angehängt wurden, sortiert nach Qualität.", "A descriptive name for this post process.": "Ein beschreibender Name für diesen Postprozess.", "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")": "Ein beschreibender Name für dieses Profil (z. B. „720p Standard“, „Twitch Stream“)", "A descriptive name for this stream file setting profile": "Ein beschreibender Name für dieses Streamdatei-Einstellungsprofil", @@ -75,6 +88,7 @@ "Add and manage authentication.": "Authentifizierung hinzufügen und verwalten.", "Add any custom headers to include when streaming a channel/episode.": "Fügen Sie alle benutzerdefinierten Header hinzu, die beim Streamen eines Kanals/einer Episode einbezogen werden sollen.", "Add as failover": "Als Failover hinzufügen", + "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.": "Fügen Sie unten mindestens einen Failover-Kanal hinzu – der Smart-Kanal bezieht seine Stream-URL vom höchstrangigen Failover zur Stream-Zeit.", "Add auto-add rule": "Auto-Hinzufüge-Regel hinzufügen", "Add available subtitle languages for this content.": "Fügen Sie verfügbare Untertitelsprachen für diesen Inhalt hinzu.", "Add backdrop/poster image URLs for this content.": "Fügen Sie Hintergrund-/Posterbild-URLs für diesen Inhalt hinzu.", @@ -215,6 +229,7 @@ "Auto-create groups/categories from TMDB genres": "Erstellen Sie automatisch Gruppen/Kategorien aus TMDB-Genres", "Auto-lookup on metadata fetch": "Automatische Suche beim Metadatenabruf", "Auto-regenerate Schedule": "Zeitplan automatisch neu generieren", + "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.": "Automatisches Streamen des höchstrangigen Failovers. Das URL-Feld ist gesperrt, solange es aktiviert ist. Nur benutzerdefinierte Kanäle.", "Auto/Default": "Auto/Standard", "Automated backups": "Automatisierte Backups", "Automatically assign sort number based on playlist order": "Weisen Sie automatisch eine Sortiernummer basierend auf der Reihenfolge der Wiedergabelisten zu", @@ -419,12 +434,14 @@ "Control how media is transcoded": "Steuern Sie, wie Medien transkodiert werden", "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.": "Steuern Sie die Anfragenparallelität für die Parallelverarbeitung und fügen Sie Verzögerungen zwischen Anfragen ein, um Rate-Limiting beim Anbieter zu vermeiden.", "Control what content is synced from the media server": "Steuern Sie, welche Inhalte vom Medienserver synchronisiert werden", + "Cookies (Netscape format)": "Cookies (Netscape-Format)", "Cookies File Path": "Cookie-Dateipfad", "Copy Changes": "Änderungen kopieren", "Copy now": "Jetzt kopieren", "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.": "Kopieren Sie den Datei-Hash von der GitHub-Release-Seite, um sicherzustellen, dass der Download nicht manipuliert wurde.", "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.": "Kopieren Sie den Datei-Hash von der GitHub-Release-Seite, um sicherzustellen, dass der Download nicht manipuliert wurde.", "Copy the file hash from the GitHub release page.": "Kopieren Sie den Datei-Hash von der GitHub-Release-Seite.", + "Could not create smart channel": "Smart-Kanal konnte nicht erstellt werden", "Could not determine the record to update. Please close the modal and try again.": "Der zu aktualisierende Datensatz konnte nicht ermittelt werden. Bitte schließen Sie das Modal und versuchen Sie es erneut.", "Could not fetch release": "Die Veröffentlichung konnte nicht abgerufen werden", "Country": "Land", @@ -439,11 +456,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "Erstellen Sie im Abschnitt „Netzwerke“ Netzwerke, um Pseudo-Live-Kanäle zu erstellen", "Create Plugin": "Plugin erstellen", "Create Token": "Token erstellen", + "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.": "Erstellen Sie aus den ausgewählten Kanälen einen benutzerdefinierten „Smart Channel“. Der Titel, das Logo und die EPG-Zuordnung der Quelle mit der höchsten Punktzahl werden kopiert. Alle ausgewählten Kanäle werden zu Failovers, sortiert nach Punktzahl, und die Playlist streamt automatisch das Top-Failover.", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "Erstellen Sie einen Alias ​​einer vorhandenen Wiedergabeliste oder einer benutzerdefinierten Wiedergabeliste, um andere Xtream-API-Anmeldeinformationen zu verwenden und gleichzeitig dieselben zugrunde liegenden Kanal-, VOD- und Serienkonfigurationen der verknüpften Wiedergabeliste zu verwenden.", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "Erstellen Sie Anmeldeinformationen und weisen Sie diese zur einfachen Authentifizierung Ihrer Playlist zu. Sie können auch verwendet werden, um auf die Xtream-API für die zugewiesenen Playlists zuzugreifen.", "Create live TV channels from your media server content": "Erstellen Sie Live-TV-Kanäle aus den Inhalten Ihres Medienservers", "Create now": "Jetzt erstellen", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "Erstellen Sie Playlists, die aus Kanälen Ihrer anderen Playlists bestehen. Gehen Sie zu „Kanäle“, um Ihrer benutzerdefinierten Playlist mehrere Kanäle hinzuzufügen.", + "Create smart channel": "Erstellen Sie einen intelligenten Kanal", "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.": "Erstellt eine neue Sicherheitsüberprüfung der aktuellen Dateien dieses Plugins. Verwenden Sie dies nach dem Aktualisieren von Plugin-Dateien auf der Festplatte oder nach einer fehlgeschlagenen Installation, um den Überprüfungsprozess ohne erneutes Hochladen erneut auszulösen.", "Credentials": "Anmeldeinformationen", "Current IDs": "Aktuelle IDs", @@ -471,6 +490,7 @@ "DVR Removed": "DVR entfernt", "DVR Status": "DVR-Status", "DVR Sync Status": "DVR-Synchronisierungsstatus", + "Daily": "Täglich", "Dashboard": "Dashboard", "Data Ownership": "Dateneigentum", "Database: Execute Query": "Datenbank: Abfrage ausführen", @@ -546,6 +566,7 @@ "Disable group channels now?": "Gruppenkanäle jetzt deaktivieren?", "Disable now": "Jetzt deaktivieren", "Disable selected": "Ausgewählte Option deaktivieren", + "Disable source channels": "Quellkanäle deaktivieren", "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.": "Deaktivieren Sie die Stream-Prüfung für alle Episoden der ausgewählten Serie. Sie werden von Stream-Probing-Jobs ausgeschlossen.", "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.": "Deaktivieren Sie die Stream-Prüfung für die ausgewählten VOD-Streams. Sie werden von Stream-Probing-Jobs ausgeschlossen.", "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.": "Deaktivieren Sie die Stream-Prüfung für die ausgewählten Kanäle. Sie werden von Stream-Probing-Jobs ausgeschlossen.", @@ -589,6 +610,7 @@ "Duration (Seconds)": "Dauer (Sekunden)", "Duration in HH:MM:SS format.": "Dauer im Format HH:MM:SS.", "Duration in seconds.": "Dauer in Sekunden.", + "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.": "Bei der geplanten oder manuellen Neubewertung werden Kanäle mit älteren Statistiken zuerst erneut überprüft. Auf 0 einstellen, um immer erneut zu prüfen. Die Aktion „Intelligenten Kanal erstellen“ verwendet nur vorhandene Statistiken und berücksichtigt diese Einstellung nicht.", "EPG": "EPG", "EPG Cache is being generated": "EPG-Cache wird generiert", "EPG Cache is being generated for selected EPGs": "Für ausgewählte EPGs wird ein EPG-Cache generiert", @@ -785,7 +807,13 @@ "Failover Channel": "Failover-Kanal", "Failover Channels": "Failover-Kanäle", "Failover Playlist": "Failover-Playlist", + "Failover Ranking": "Failover-Ranking", + "Failover Rescoring": "Failover-Neubewertung", + "Failover groups will be re-scored in the background. You can keep using the app while this runs.": "Failovergruppen werden im Hintergrund neu bewertet. Sie können die App während der Ausführung weiterhin verwenden.", + "Failover rescoring queued": "Failover-Neubewertung in der Warteschlange", "Failovers": "Failover", + "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.": "Failover nach Punktzahl sortiert. Gespeichert von der letzten Zusammenführung oder Neubewertung – klicken Sie auf „Jetzt neu bewerten“, um eine Neuberechnung anhand der aktuellen Stream-Statistiken durchzuführen.", + "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.": "Failover werden im Hintergrund neu bewertet. Aktualisieren Sie diese Seite gleich, um das aktualisierte Ranking zu sehen.", "Fetch & Install": "Abrufen und installieren", "Fetch & Update": "Abrufen und aktualisieren", "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.": "IDs für alle aktivierten Playlist-Serien abrufen? Wenn deaktiviert, wird es nur für Serien der ausgewählten Playlist abgerufen.", @@ -993,6 +1021,7 @@ "John Doe, Jane Smith": "John Doe, Jane Smith", "Join us on Discord": "Tritt uns auf Discord bei", "Keep cache permanently (disable expiry cleanup)": "Cache dauerhaft behalten (Ablaufbereinigung deaktivieren)", + "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.": "Hält die Failover-Reihenfolge mit der aktuellen Stream-Qualität synchronisiert. Betrifft drei Stellen: die geplante Neubewertung (das Intervall unten), die Aktion „Jetzt neu bewerten“ pro Kanal und die Massenaktion „Intelligenten Kanal erstellen“. Die oben unter „Automatische Zusammenführung“ angegebene Prioritätsreihenfolge wird für die tatsächliche Wertung in allen drei Fällen wiederverwendet.", "Kinopoisk Rating Count": "Anzahl der Kinopoisk-Bewertungen", "Kinopoisk URL": "Kinopoisk-URL", "Label": "Etikett", @@ -1019,6 +1048,7 @@ "Leave blank to keep the current password": "Lassen Sie das Feld leer, um das aktuelle Passwort beizubehalten", "Leave blank to use the same provider as the primary account.": "Lassen Sie das Feld leer, um denselben Anbieter wie das primäre Konto zu verwenden.", "Leave empty for direct stream proxying": "Für direktes Stream-Proxying leer lassen", + "Leave empty to copy the highest-scoring source's title.": "Lassen Sie das Feld leer, um den Titel der Quelle mit der höchsten Bewertung zu kopieren.", "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.": "Lassen Sie das Feld leer, um die .strm-Dateigenerierung für VOD zu deaktivieren. Priorität: VOD > Gruppe > Global.", "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.": "Lassen Sie das Feld leer, um die Generierung von .strm-Dateien für Serien zu deaktivieren. Priorität: Serie > Kategorie > Global.", "Leave empty to remove": "Zum Entfernen leer lassen", @@ -1091,6 +1121,7 @@ "MPAA rating classification.": "MPAA-Bewertungsklassifizierung.", "Main actors in the content.": "Hauptakteure des Inhalts.", "Make log files viewable": "Machen Sie Protokolldateien sichtbar", + "Make smart channel": "Erstellen Sie einen intelligenten Kanal", "Manage": "Verwalten", "Manage API Tokens": "API-Tokens verwalten", "Manage Assets": "Assets verwalten", @@ -1182,6 +1213,7 @@ "Missing checksum": "Fehlende Prüfsumme", "Missing credentials": "Fehlende Anmeldeinformationen", "Missing information": "Fehlende Informationen", + "Mixed playlists not supported": "Gemischte Wiedergabelisten werden nicht unterstützt", "Mode": "Modus", "Model": "Modell", "Modified": "Geändert", @@ -1291,6 +1323,7 @@ "Not probed yet": "Noch nicht untersucht", "Not set": "Nicht festgelegt", "Not started": "Nicht gestartet", + "Not yet probed — run a probe on this channel to capture stream details.": "Noch nicht geprüft – führen Sie eine Prüfung auf diesem Kanal durch, um Stream-Details zu erfassen.", "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.": "Hinweis: Qualität, Video-Codec, Audio und HDR-Platzhalter erfordern eine Stream-Prüfung. Kanäle/Episoden, die nicht geprüft wurden, werden auf manuelle Werte aus der Playlist-Quelle zurückgegriffen – einige Platzhalter werden möglicherweise leer angezeigt.", "Notification Message": "Benachrichtigungsnachricht", "Notification Subject": "Betreff der Benachrichtigung", @@ -1310,6 +1343,7 @@ "Number of streams available for HDHR and Xtream API service (if using).": "Anzahl der für den HDHR- und Xtream-API-Dienst verfügbaren Streams (falls verwendet).", "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).": "Anzahl der für diese Playlist verfügbaren Streams (gilt nur für benutzerdefinierte Kanäle, die dieser benutzerdefinierten Playlist zugewiesen sind).", "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.": "Anzahl der für diesen Anbieter verfügbaren Streams. Bei einem anderen Wert als 0 wird verhindert, dass Streams gestartet werden, wenn die Anzahl der aktiven Streams diesen Wert überschreitet.", + "Off": "Aus", "Offline": "Offline", "Online": "Online", "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.": "Nur VOD-Kanäle in diesen Gruppen sind zugänglich. Lassen Sie das Feld leer, um alle VOD-Gruppen zuzulassen.", @@ -1373,6 +1407,7 @@ "Parallel processing": "Parallele Verarbeitung", "Password": "Passwort", "Password for playlist access.": "Passwort für den Zugriff auf die Playlist.", + "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".": "Fügen Sie Cookies.txt-Inhalte für authentifizierte Streams ein (z. B. nur für YouTube-Mitglieder, mit Altersbeschränkung). Holen Sie sich Cookies mit einer Browsererweiterung wie „Cookies.txt LOKAL abrufen“.", "Path Preview": "Pfadvorschau", "Path Validation Failed": "Pfadvalidierung fehlgeschlagen", "Path structure (folders)": "Pfadstruktur (Ordner)", @@ -1382,6 +1417,7 @@ "Pending Review": "Ausstehende Überprüfung", "Pending Trust": "Ausstehendes Vertrauen", "Performance": "Leistung", + "Periodic rescoring": "Regelmäßige Neubewertung", "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.": "Entfernt die Plugin-Dateien dauerhaft vom Server und löscht den Registrierungseintrag, die Einstellungen und den Ausführungsverlauf. Dies kann nicht rückgängig gemacht werden.", "Permanently removes this install log entry. The plugin itself (if installed) is not affected.": "Entfernt diesen Installationsprotokolleintrag dauerhaft. Das Plugin selbst (falls installiert) ist nicht betroffen.", "Permissions": "Berechtigungen", @@ -1528,6 +1564,7 @@ "Probe failed": "Analyse fehlgeschlagen", "Probe now": "Jetzt sondieren", "Probe ran but returned no stream info": "Probe wurde ausgeführt, gab jedoch keine Stream-Informationen zurück", + "Probe ran but returned no usable details.": "Die Probe wurde ausgeführt, lieferte jedoch keine verwertbaren Details zurück.", "Probe streams after sync": "Testen Sie Streams nach der Synchronisierung", "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Untersuchen Sie die Episoden der ausgewählten Serie mit ffprobe, um Stream-Metadaten (Codec, Auflösung, Bitrate, HDR) zu sammeln. Diese Daten ermöglichen die Benennung des Trash Guide mit Stream-Statistik-basierter Erkennung.", "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Untersuchen Sie die ausgewählten VOD-Streams mit ffprobe, um Stream-Metadaten (Codec, Auflösung, Bitrate, HDR) zu sammeln. Diese Daten ermöglichen die Benennung des Trash Guide mit Stream-Statistik-basierter Erkennung.", @@ -1536,6 +1573,7 @@ "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Untersuchen Sie dieses VOD mit ffprobe, um Stream-Metadaten (Codec, Auflösung, Bitrate, HDR) zu sammeln. Diese Daten ermöglichen die Benennung des Trash Guide mit Stream-Statistik-basierter Erkennung.", "Probe timeout (seconds)": "Probe-Timeout (Sekunden)", "Probed": "Untersucht", + "Probing is disabled for this channel.": "Die Prüfung ist für diesen Kanal deaktiviert.", "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.": "Die Prüfung ist für diesen Kanal deaktiviert. Aktivieren Sie es im Bearbeitungsformular des Kanals, um die Erfassung von Sondendaten zu ermöglichen.", "Probing started": "Die Sondierung hat begonnen", "Process": "Verfahren", @@ -1605,6 +1643,7 @@ "Quality & Options": "Qualität & Optionen", "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3": "Qualitätsauswahl (best, worst, 720p usw.), gefolgt von optionalen Streamlink-Flags. Beispiel: best --hls-live-edge 3", "Queue Manager": "Warteschlangenmanager", + "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.": "Einen einmaligen Failover-Rescore für diese Playlist in die Warteschlange stellen? Veraltete Kanäle werden erneut überprüft (vorbehaltlich des konfigurierten Veraltungsfensters) und Failovers neu sortiert, sodass die Quelle mit der höchsten Qualität zuerst angezeigt wird.", "Queue a plugin action from the page header to create the first run.": "Stellen Sie eine Plugin-Aktion aus dem Seitenkopf in die Warteschlange, um die erste Ausführung zu erstellen.", "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.": "Stellen Sie einen Scan vom Header in die Warteschlange, um den ersten Lauf zu generieren. Dadurch werden die Live-Aktivität, der Laufverlauf und der Laufdetailbildschirm angezeigt.", "Queue reset": "Warteschlange zurückgesetzt", @@ -1613,6 +1652,7 @@ "Quick Actions": "Schnelle Aktionen", "Ran At": "Ausgeführt um", "Ran at": "Ausgeführt um", + "Ranked sources": "Bewertete Quellen", "Rate Limit (requests/second)": "Ratenlimit (Anfragen/Sekunde)", "Rating": "Bewertung", "Rating (5 based)": "Bewertung (5 basierend)", @@ -1620,7 +1660,10 @@ "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.": "Deaktivierte Kanäle, die als live erkannt werden, wieder aktivieren. Erfordert, dass \"Alle Kanäle scannen\" aktiviert ist.", "Re-enable live channels": "Live-Kanäle wieder aktivieren", "Re-probe": "Erneut prüfen", + "Re-probe channels older than (days)": "Kanäle, die älter als (Tage) sind, erneut prüfen", "Re-run this mapping everytime the EPG is synced?": "Diese Zuordnung jedes Mal erneut ausführen, wenn der EPG synchronisiert wird?", + "Re-score failovers now": "Jetzt Failover neu bewerten", + "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.": "Bewerten Sie die Failovers dieses Kanals erneut anhand der aktuellen Stream-Statistiken. Veraltete Kanäle können erneut überprüft werden (vorbehaltlich des Veraltungsfensters der Wiedergabeliste). Der Master-Kanal wird nie geändert – nur die Failover-Reihenfolge ändert sich.", "Recall Memories": "Erinnern Sie sich an Erinnerungen", "Recent Plugin Installs": "Aktuelle Plugin-Installationen", "Recent Runs": "Letzte Läufe", @@ -1704,6 +1747,9 @@ "Required to send emails, if your provider requires authentication.": "Erforderlich zum Versenden von E-Mails, wenn Ihr Provider eine Authentifizierung erfordert.", "Required to send emails.": "Erforderlich zum Versenden von E-Mails.", "Rescan storage": "Speicher erneut scannen", + "Rescore": "Neubewertung", + "Rescore now": "Jetzt neu bewerten", + "Rescoring queued": "Neubewertung in der Warteschlange", "Reset": "Zurücksetzen", "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.": "Setzen Sie den EPG-Status zurück, damit er erneut verarbeitet werden kann. Führen Sie diese Aktion nur aus, wenn Sie Probleme mit der EPG-Synchronisierung haben.", "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.": "Setzen Sie die Ergebnisse von „Suchen und Ersetzen“ auf die EPG-Standardeinstellungen zurück. Dadurch werden alle in der ausgewählten Spalte festgelegten benutzerdefinierten Werte entfernt.", @@ -1802,6 +1848,7 @@ "Schedule Start Time": "Planen Sie die Startzeit", "Schedule Type": "Zeitplantyp", "Schedule Window": "Zeitplanfenster", + "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.": "Planen Sie eine wiederkehrende Neubewertung für jede Failover-Gruppe in dieser Playlist. Master-Kanäle werden niemals hochgestuft oder ersetzt – nur die Failover-Sortierreihenfolge wird geändert. Aus = Nur manuell neu bewerten über die Schaltfläche „Jetzt neu bewerten“.", "Schedule window is already one week": "Das Zeitplanfenster beträgt bereits eine Woche", "Scheduled": "Geplant", "Scheduled Recordings": "Geplante Aufnahmen", @@ -2012,6 +2059,14 @@ "Simple authentication for playlist access.": "Einfache Authentifizierung für den Playlist-Zugriff.", "Size": "Größe", "Skip channels without EPG ID": "Kanäle ohne EPG-ID überspringen", + "Smart": "Schlau", + "Smart channel": "Intelligenter Kanal", + "Smart channel created": "Smart-Kanal erstellt", + "Smart channel without failovers won't stream.": "Smart Channel ohne Failover wird nicht gestreamt.", + "Smart channel — streams the highest-ranked failover automatically": "Intelligenter Kanal: Streamt automatisch das höchstrangige Failover", + "Smart channels cannot be sources": "Intelligente Kanäle können keine Quellen sein", + "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.": "Intelligente Kanäle müssen aus Quellen innerhalb einer einzelnen Playlist erstellt werden. Grenzen Sie Ihre Auswahl ein und versuchen Sie es erneut.", + "Some channels were skipped": "Einige Kanäle wurden übersprungen", "Sort": "Sortieren", "Sort Alpha": "Alpha sortieren", "Sort Alpha Configs": "Alpha-Konfigurationen sortieren", @@ -2193,6 +2248,8 @@ "The database table to query.": "Die abzufragende Datenbanktabelle.", "The default transcoding profile used by the in-app player for Live content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "Das standardmäßige Transkodierungsprofil, das vom In-App-Player für Live-Inhalte verwendet wird. Ein kanalspezifisches Stream-Profil (falls gesetzt) hat Vorrang. Lassen Sie das Feld leer, um die Transkodierung zu deaktivieren (einige Streams können im Player möglicherweise nicht abgespielt werden).", "The default transcoding profile used by the in-app player for VOD/Series content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "Das Standard-Transkodierungsprofil, das vom In-App-Player für VOD-/Serieninhalte verwendet wird. Ein kanalspezifisches Stream-Profil (falls gesetzt) hat Vorrang. Lassen Sie das Feld leer, um die Transkodierung zu deaktivieren (einige Streams können im Player möglicherweise nicht abgespielt werden).", + "The default transcoding profile used for the in-app player for Live content. Leave empty to disable transcoding (some streams may not be playable in the player).": "Das standardmäßige Transkodierungsprofil, das für den In-App-Player für Live-Inhalte verwendet wird. Lassen Sie das Feld leer, um die Transkodierung zu deaktivieren (einige Streams können im Player möglicherweise nicht abgespielt werden).", + "The default transcoding profile used for the in-app player for VOD/Series content. Leave empty to disable transcoding (some streams may not be playable in the player).": "Das Standard-Transkodierungsprofil, das für den In-App-Player für VOD-/Serieninhalte verwendet wird. Lassen Sie das Feld leer, um die Transkodierung zu deaktivieren (einige Streams können im Player möglicherweise nicht abgespielt werden).", "The event that will trigger this post process.": "Das Ereignis, das diesen Postprozess auslöst.", "The event that will trigger this post process. \"VOD/Series Stream Files Synced\" fires after the respective .strm file sync completes (requires .strm sync to be enabled on the playlist).": "Das Ereignis, das diesen Postprozess auslöst. „VOD/Series Stream Files Synced“ wird ausgelöst, nachdem die entsprechende .strm-Dateisynchronisierung abgeschlossen ist (erfordert die Aktivierung der .strm-Synchronisierung in der Wiedergabeliste).", "The file extension of the VOD container (e.g., mp4, mkv, etc.).": "Die Dateierweiterung des VOD-Containers (z. B. mp4, mkv usw.).", @@ -2499,6 +2556,7 @@ "View the EPG channel mapping jobs and progress here.": "Sehen Sie sich hier die EPG-Kanalzuordnungsaufträge und den Fortschritt an.", "View/Update Unique Identifier": "Eindeutige Kennung anzeigen/aktualisieren", "Viewer ID": "Zuschauer-ID", + "Virtual channel title": "Titel des virtuellen Kanals", "Wait this many seconds after sync completes before triggering the library refresh": "Warten Sie nach Abschluss der Synchronisierung so viele Sekunden, bevor Sie die Aktualisierung der Bibliothek auslösen", "Wait until a specific date/time before starting the broadcast.": "Warten Sie bis zu einem bestimmten Datum/einer bestimmten Uhrzeit, bevor Sie mit der Übertragung beginnen.", "Warning": "Warnung", @@ -2508,6 +2566,7 @@ "WebDAV Password": "WebDAV-Passwort", "WebDAV Username": "WebDAV-Benutzername", "WebSocket Connection Test": "WebSocket-Verbindungstest", + "Weekly": "Wöchentlich", "Weight": "Gewicht", "Weight (for shuffle)": "Gewicht (zum Mischen)", "What does this plugin do?": "Was macht dieses Plugin?", @@ -2565,6 +2624,7 @@ "When enabled, the playlist will fetch items by category.": "Wenn diese Option aktiviert ist, ruft die Wiedergabeliste Elemente nach Kategorie ab.", "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.": "Wenn diese Option aktiviert ist, ruft die Wiedergabeliste Elemente nach Kategorie ab. Dies verlangsamt möglicherweise den Importvorgang, kann jedoch bei größeren Wiedergabelisten hilfreich sein, bei denen beim gleichzeitigen Abrufen aller Elemente eine Zeitüberschreitung auftritt.", "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.": "Wenn diese Option aktiviert ist, versucht der Proxy, Streams zu starten, selbst wenn das vom Anbieter gemeldete Verbindungslimit erreicht wurde.", + "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.": "Wenn diese Option aktiviert ist, werden die ausgewählten Quellkanäle deaktiviert, nachdem sie als Failover hinzugefügt wurden. Sie werden nur über den neuen Smart-Kanal erreichbar sein.", "When enabled, the user will be prompted to set a new password before they can use the application.": "Wenn diese Option aktiviert ist, wird der Benutzer aufgefordert, ein neues Passwort festzulegen, bevor er die Anwendung verwenden kann.", "When enabled, there will be an additional navigation item (Logs) to view the log file content.": "Wenn diese Option aktiviert ist, gibt es ein zusätzliches Navigationselement (Protokolle), um den Inhalt der Protokolldatei anzuzeigen.", "When enabled, this network will continuously broadcast content according to the schedule.": "Wenn es aktiviert ist, sendet dieses Netzwerk kontinuierlich Inhalte gemäß dem Zeitplan.", @@ -2611,6 +2671,7 @@ "Yes, generate cache now": "Ja, jetzt Cache generieren", "Yes, process now": "Ja, jetzt bearbeiten", "Yes, refresh now": "Ja, jetzt aktualisieren", + "Yes, rescore now": "Ja, jetzt neu bewerten", "Yes, reset now": "Ja, jetzt zurücksetzen", "Yes, sync now": "Ja, jetzt synchronisieren", "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.": "Sie sind ein hilfreicher KI-Assistent, der in den m3u-Editor integriert ist. Sie helfen Benutzern bei der Verwaltung von Playlists, EPG-Daten, Streams, Kanälen und anderen Funktionen. Seien Sie prägnant und genau.", @@ -2632,6 +2693,7 @@ "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.": "Ihr TMDB-API-Schlüssel (v3-Authentifizierung). Sie können eines kostenlos bei themoviedb.org erhalten.", "Your m3u proxy API key": "Ihr m3u-Proxy-API-Schlüssel", "Your preferences have been saved successfully.": "Ihre Einstellungen wurden erfolgreich gespeichert.", + "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.": "Ihre Auswahl umfasst einen oder mehrere vorhandene Smart-Kanäle. Wählen Sie stattdessen reine Anbieterkanäle aus oder entfernen Sie zuerst die Smart-Channel-Flagge.", "aac": "aac", "and how to use it": "und wie man es benutzt", "e.g. 2024": "z.B. 2024", @@ -2679,4 +2741,4 @@ "veryfast": "sehr schnell", "your-api-key-here": "Ihr-API-Schlüssel-hier", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "yt-dlp-Formatauswahl, gefolgt von optionalen Flags. Beispiel: bestvideo+bestaudio/best --no-playlist" -} \ No newline at end of file +} diff --git a/lang/en.json b/lang/en.json index ce1f7ffb6..fab8cc110 100644 --- a/lang/en.json +++ b/lang/en.json @@ -17,16 +17,28 @@ "1": 1, "1-1000, higher = more preferred": "1-1000, higher = more preferred", "10 based rating of the VOD content.": "10 based rating of the VOD content.", - "2": 10, - "3": 2, - "4": 3, - "5": 4, - "6": 5, - "7": 6, - "8": 7, + "2": 9, + "3": 10, + "4": 2, + "5": 3, + "6": 4, + "7": 5, + "8": 6, "8.7": "8.7", - "9": 8, - "10": 9, + "9": 7, + "10": 8, + "11": 0, + "12": 1, + "13": 10, + "14": 2, + "15": 3, + "16": 4, + "17": 5, + "18": 6, + "19": 7, + "20": 8, + "21": 9, + ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.": ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.", ":images images": ":images images", ":name installed": ":name installed", ":name updated": ":name updated", @@ -35,6 +47,7 @@ ":uploaded uploaded · :cached cached": ":uploaded uploaded · :cached cached", "A SHA-256 checksum is required to stage this update.": "A SHA-256 checksum is required to stage this update.", "A channel dedicated to classic movies from the golden age of cinema": "A channel dedicated to classic movies from the golden age of cinema", + "A custom channel was created with the selected sources attached as failovers, ranked by quality.": "A custom channel was created with the selected sources attached as failovers, ranked by quality.", "A descriptive name for this post process.": "A descriptive name for this post process.", "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")": "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")", "A descriptive name for this stream file setting profile": "A descriptive name for this stream file setting profile", @@ -75,6 +88,7 @@ "Add and manage authentication.": "Add and manage authentication.", "Add any custom headers to include when streaming a channel/episode.": "Add any custom headers to include when streaming a channel/episode.", "Add as failover": "Add as failover", + "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.": "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.", "Add auto-add rule": "Add auto-add rule", "Add available subtitle languages for this content.": "Add available subtitle languages for this content.", "Add backdrop/poster image URLs for this content.": "Add backdrop/poster image URLs for this content.", @@ -215,6 +229,7 @@ "Auto-create groups/categories from TMDB genres": "Auto-create groups/categories from TMDB genres", "Auto-lookup on metadata fetch": "Auto-lookup on metadata fetch", "Auto-regenerate Schedule": "Auto-regenerate Schedule", + "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.": "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.", "Auto/Default": "Auto/Default", "Automated backups": "Automated backups", "Automatically assign sort number based on playlist order": "Automatically assign sort number based on playlist order", @@ -419,12 +434,14 @@ "Control how media is transcoded": "Control how media is transcoded", "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.": "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.", "Control what content is synced from the media server": "Control what content is synced from the media server", + "Cookies (Netscape format)": "Cookies (Netscape format)", "Cookies File Path": "Cookies File Path", "Copy Changes": "Copy Changes", "Copy now": "Copy now", "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.": "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.", "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.": "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.", "Copy the file hash from the GitHub release page.": "Copy the file hash from the GitHub release page.", + "Could not create smart channel": "Could not create smart channel", "Could not determine the record to update. Please close the modal and try again.": "Could not determine the record to update. Please close the modal and try again.", "Could not fetch release": "Could not fetch release", "Country": "Country", @@ -439,11 +456,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "Create Networks in the Networks section to build pseudo-live channels", "Create Plugin": "Create Plugin", "Create Token": "Create Token", + "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.": "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.", "Create live TV channels from your media server content": "Create live TV channels from your media server content", "Create now": "Create now", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.", + "Create smart channel": "Create smart channel", "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.": "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.", "Credentials": "Credentials", "Current IDs": "Current IDs", @@ -471,6 +490,7 @@ "DVR Removed": "DVR Removed", "DVR Status": "DVR Status", "DVR Sync Status": "DVR Sync Status", + "Daily": "Daily", "Dashboard": "Dashboard", "Data Ownership": "Data Ownership", "Database: Execute Query": "Database: Execute Query", @@ -546,6 +566,7 @@ "Disable group channels now?": "Disable group channels now?", "Disable now": "Disable now", "Disable selected": "Disable selected", + "Disable source channels": "Disable source channels", "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.": "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.", "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.": "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.", "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.": "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.", @@ -589,6 +610,7 @@ "Duration (Seconds)": "Duration (Seconds)", "Duration in HH:MM:SS format.": "Duration in HH:MM:SS format.", "Duration in seconds.": "Duration in seconds.", + "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.": "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.", "EPG": "EPG", "EPG Cache is being generated": "EPG Cache is being generated", "EPG Cache is being generated for selected EPGs": "EPG Cache is being generated for selected EPGs", @@ -785,7 +807,13 @@ "Failover Channel": "Failover Channel", "Failover Channels": "Failover Channels", "Failover Playlist": "Failover Playlist", + "Failover Ranking": "Failover Ranking", + "Failover Rescoring": "Failover Rescoring", + "Failover groups will be re-scored in the background. You can keep using the app while this runs.": "Failover groups will be re-scored in the background. You can keep using the app while this runs.", + "Failover rescoring queued": "Failover rescoring queued", "Failovers": "Failovers", + "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.": "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.", + "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.": "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.", "Fetch & Install": "Fetch & Install", "Fetch & Update": "Fetch & Update", "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.": "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.", @@ -993,6 +1021,7 @@ "John Doe, Jane Smith": "John Doe, Jane Smith", "Join us on Discord": "Join us on Discord", "Keep cache permanently (disable expiry cleanup)": "Keep cache permanently (disable expiry cleanup)", + "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.": "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.", "Kinopoisk Rating Count": "Kinopoisk Rating Count", "Kinopoisk URL": "Kinopoisk URL", "Label": "Label", @@ -1019,6 +1048,7 @@ "Leave blank to keep the current password": "Leave blank to keep the current password", "Leave blank to use the same provider as the primary account.": "Leave blank to use the same provider as the primary account.", "Leave empty for direct stream proxying": "Leave empty for direct stream proxying", + "Leave empty to copy the highest-scoring source's title.": "Leave empty to copy the highest-scoring source's title.", "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.": "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.", "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.": "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.", "Leave empty to remove": "Leave empty to remove", @@ -1091,6 +1121,7 @@ "MPAA rating classification.": "MPAA rating classification.", "Main actors in the content.": "Main actors in the content.", "Make log files viewable": "Make log files viewable", + "Make smart channel": "Make smart channel", "Manage": "Manage", "Manage API Tokens": "Manage API Tokens", "Manage Assets": "Manage Assets", @@ -1182,6 +1213,7 @@ "Missing checksum": "Missing checksum", "Missing credentials": "Missing credentials", "Missing information": "Missing information", + "Mixed playlists not supported": "Mixed playlists not supported", "Mode": "Mode", "Model": "Model", "Modified": "Modified", @@ -1291,6 +1323,7 @@ "Not probed yet": "Not probed yet", "Not set": "Not set", "Not started": "Not started", + "Not yet probed — run a probe on this channel to capture stream details.": "Not yet probed — run a probe on this channel to capture stream details.", "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.": "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.", "Notification Message": "Notification Message", "Notification Subject": "Notification Subject", @@ -1310,6 +1343,7 @@ "Number of streams available for HDHR and Xtream API service (if using).": "Number of streams available for HDHR and Xtream API service (if using).", "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).": "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).", "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.": "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.", + "Off": "Off", "Offline": "Offline", "Online": "Online", "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.": "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.", @@ -1373,6 +1407,7 @@ "Parallel processing": "Parallel processing", "Password": "Password", "Password for playlist access.": "Password for playlist access.", + "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".": "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".", "Path Preview": "Path Preview", "Path Validation Failed": "Path Validation Failed", "Path structure (folders)": "Path structure (folders)", @@ -1382,6 +1417,7 @@ "Pending Review": "Pending Review", "Pending Trust": "Pending Trust", "Performance": "Performance", + "Periodic rescoring": "Periodic rescoring", "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.": "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.", "Permanently removes this install log entry. The plugin itself (if installed) is not affected.": "Permanently removes this install log entry. The plugin itself (if installed) is not affected.", "Permissions": "Permissions", @@ -1528,6 +1564,7 @@ "Probe failed": "Probe failed", "Probe now": "Probe now", "Probe ran but returned no stream info": "Probe ran but returned no stream info", + "Probe ran but returned no usable details.": "Probe ran but returned no usable details.", "Probe streams after sync": "Probe streams after sync", "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.", "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.", @@ -1536,6 +1573,7 @@ "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.", "Probe timeout (seconds)": "Probe timeout (seconds)", "Probed": "Probed", + "Probing is disabled for this channel.": "Probing is disabled for this channel.", "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.": "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.", "Probing started": "Probing started", "Process": "Process", @@ -1605,6 +1643,7 @@ "Quality & Options": "Quality & Options", "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3": "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3", "Queue Manager": "Queue Manager", + "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.": "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.", "Queue a plugin action from the page header to create the first run.": "Queue a plugin action from the page header to create the first run.", "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.": "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.", "Queue reset": "Queue reset", @@ -1613,6 +1652,7 @@ "Quick Actions": "Quick Actions", "Ran At": "Ran At", "Ran at": "Ran at", + "Ranked sources": "Ranked sources", "Rate Limit (requests/second)": "Rate Limit (requests/second)", "Rating": "Rating", "Rating (5 based)": "Rating (5 based)", @@ -1620,7 +1660,10 @@ "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.": "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.", "Re-enable live channels": "Re-enable live channels", "Re-probe": "Re-probe", + "Re-probe channels older than (days)": "Re-probe channels older than (days)", "Re-run this mapping everytime the EPG is synced?": "Re-run this mapping everytime the EPG is synced?", + "Re-score failovers now": "Re-score failovers now", + "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.": "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.", "Recall Memories": "Recall Memories", "Recent Plugin Installs": "Recent Plugin Installs", "Recent Runs": "Recent Runs", @@ -1704,6 +1747,9 @@ "Required to send emails, if your provider requires authentication.": "Required to send emails, if your provider requires authentication.", "Required to send emails.": "Required to send emails.", "Rescan storage": "Rescan storage", + "Rescore": "Rescore", + "Rescore now": "Rescore now", + "Rescoring queued": "Rescoring queued", "Reset": "Reset", "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.": "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.", "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.": "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.", @@ -1802,6 +1848,7 @@ "Schedule Start Time": "Schedule Start Time", "Schedule Type": "Schedule Type", "Schedule Window": "Schedule Window", + "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.": "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.", "Schedule window is already one week": "Schedule window is already one week", "Scheduled": "Scheduled", "Scheduled Recordings": "Scheduled Recordings", @@ -2012,6 +2059,14 @@ "Simple authentication for playlist access.": "Simple authentication for playlist access.", "Size": "Size", "Skip channels without EPG ID": "Skip channels without EPG ID", + "Smart": "Smart", + "Smart channel": "Smart channel", + "Smart channel created": "Smart channel created", + "Smart channel without failovers won't stream.": "Smart channel without failovers won't stream.", + "Smart channel — streams the highest-ranked failover automatically": "Smart channel — streams the highest-ranked failover automatically", + "Smart channels cannot be sources": "Smart channels cannot be sources", + "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.": "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.", + "Some channels were skipped": "Some channels were skipped", "Sort": "Sort", "Sort Alpha": "Sort Alpha", "Sort Alpha Configs": "Sort Alpha Configs", @@ -2193,6 +2248,8 @@ "The database table to query.": "The database table to query.", "The default transcoding profile used by the in-app player for Live content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "The default transcoding profile used by the in-app player for Live content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).", "The default transcoding profile used by the in-app player for VOD/Series content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "The default transcoding profile used by the in-app player for VOD/Series content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).", + "The default transcoding profile used for the in-app player for Live content. Leave empty to disable transcoding (some streams may not be playable in the player).": "The default transcoding profile used for the in-app player for Live content. Leave empty to disable transcoding (some streams may not be playable in the player).", + "The default transcoding profile used for the in-app player for VOD/Series content. Leave empty to disable transcoding (some streams may not be playable in the player).": "The default transcoding profile used for the in-app player for VOD/Series content. Leave empty to disable transcoding (some streams may not be playable in the player).", "The event that will trigger this post process.": "The event that will trigger this post process.", "The event that will trigger this post process. \"VOD/Series Stream Files Synced\" fires after the respective .strm file sync completes (requires .strm sync to be enabled on the playlist).": "The event that will trigger this post process. \"VOD/Series Stream Files Synced\" fires after the respective .strm file sync completes (requires .strm sync to be enabled on the playlist).", "The file extension of the VOD container (e.g., mp4, mkv, etc.).": "The file extension of the VOD container (e.g., mp4, mkv, etc.).", @@ -2499,6 +2556,7 @@ "View the EPG channel mapping jobs and progress here.": "View the EPG channel mapping jobs and progress here.", "View/Update Unique Identifier": "View/Update Unique Identifier", "Viewer ID": "Viewer ID", + "Virtual channel title": "Virtual channel title", "Wait this many seconds after sync completes before triggering the library refresh": "Wait this many seconds after sync completes before triggering the library refresh", "Wait until a specific date/time before starting the broadcast.": "Wait until a specific date/time before starting the broadcast.", "Warning": "Warning", @@ -2508,6 +2566,7 @@ "WebDAV Password": "WebDAV Password", "WebDAV Username": "WebDAV Username", "WebSocket Connection Test": "WebSocket Connection Test", + "Weekly": "Weekly", "Weight": "Weight", "Weight (for shuffle)": "Weight (for shuffle)", "What does this plugin do?": "What does this plugin do?", @@ -2565,6 +2624,7 @@ "When enabled, the playlist will fetch items by category.": "When enabled, the playlist will fetch items by category.", "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.": "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.", "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.": "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.", + "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.": "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.", "When enabled, the user will be prompted to set a new password before they can use the application.": "When enabled, the user will be prompted to set a new password before they can use the application.", "When enabled, there will be an additional navigation item (Logs) to view the log file content.": "When enabled, there will be an additional navigation item (Logs) to view the log file content.", "When enabled, this network will continuously broadcast content according to the schedule.": "When enabled, this network will continuously broadcast content according to the schedule.", @@ -2611,6 +2671,7 @@ "Yes, generate cache now": "Yes, generate cache now", "Yes, process now": "Yes, process now", "Yes, refresh now": "Yes, refresh now", + "Yes, rescore now": "Yes, rescore now", "Yes, reset now": "Yes, reset now", "Yes, sync now": "Yes, sync now", "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.": "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.", @@ -2632,6 +2693,7 @@ "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.": "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.", "Your m3u proxy API key": "Your m3u proxy API key", "Your preferences have been saved successfully.": "Your preferences have been saved successfully.", + "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.": "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.", "aac": "aac", "and how to use it": "and how to use it", "e.g. 2024": "e.g. 2024", diff --git a/lang/es.json b/lang/es.json index 744afe7d5..ea7f7e730 100644 --- a/lang/es.json +++ b/lang/es.json @@ -16,8 +16,6 @@ "01:30:00": "01:30:00", "1": "1", "1-1000, higher = more preferred": "1-1000, mayor = más preferido", - "10": "10", - "10 based rating of the VOD content.": "Clasificación basada en 10 del contenido VOD.", "2": "2", "3": "3", "4": "4", @@ -27,6 +25,20 @@ "8": "8", "8.7": "8.7", "9": "9", + "10": "10", + "10 based rating of the VOD content.": "Clasificación basada en 10 del contenido VOD.", + "11": "0", + "12": "1", + "13": "10", + "14": "2", + "15": "3", + "16": "4", + "17": "5", + "18": "6", + "19": "7", + "20": "8", + "21": "9", + ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.": ":count se omitieron los canales inteligentes: los canales inteligentes no se pueden utilizar como conmutación por error en sí mismos.", ":images images": ":images imágenes", ":name installed": ":name instalado", ":name updated": ":name actualizado", @@ -35,6 +47,7 @@ ":uploaded uploaded · :cached cached": ":uploaded subido · :cached almacenado en caché", "A SHA-256 checksum is required to stage this update.": "Se requiere una suma de comprobación SHA-256 para preparar esta actualización.", "A channel dedicated to classic movies from the golden age of cinema": "Un canal dedicado a películas clásicas de la época dorada del cine.", + "A custom channel was created with the selected sources attached as failovers, ranked by quality.": "Se creó un canal personalizado con las fuentes seleccionadas adjuntas como conmutación por error, clasificadas por calidad.", "A descriptive name for this post process.": "Un nombre descriptivo para este proceso de publicación.", "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")": "Un nombre descriptivo para este perfil (por ejemplo, \"Estándar 720p\", \"Transmisión de Twitch\")", "A descriptive name for this stream file setting profile": "Un nombre descriptivo para este perfil de configuración de archivo continuo", @@ -75,6 +88,7 @@ "Add and manage authentication.": "Agregue y administre la autenticación.", "Add any custom headers to include when streaming a channel/episode.": "Agregue encabezados personalizados para incluirlos al transmitir un canal/episodio.", "Add as failover": "Agregar como conmutación por error", + "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.": "Agregue al menos un canal de conmutación por error a continuación: el canal inteligente toma su URL de transmisión de la conmutación por error mejor clasificada en el momento de la transmisión.", "Add auto-add rule": "Agregar regla de adición automática", "Add available subtitle languages for this content.": "Agregue los idiomas de subtítulos disponibles para este contenido.", "Add backdrop/poster image URLs for this content.": "Agregue URL de imágenes de fondo/póster para este contenido.", @@ -215,6 +229,7 @@ "Auto-create groups/categories from TMDB genres": "Crear automáticamente grupos/categorías a partir de géneros TMDB", "Auto-lookup on metadata fetch": "Búsqueda automática en la recuperación de metadatos", "Auto-regenerate Schedule": "Programación de regeneración automática", + "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.": "Transmite automáticamente la conmutación por error mejor clasificada. El campo URL está bloqueado mientras está activado. Solo canales personalizados.", "Auto/Default": "Automático/predeterminado", "Automated backups": "Copias de seguridad automatizadas", "Automatically assign sort number based on playlist order": "Asigne automáticamente un número de clasificación según el orden de la lista de reproducción", @@ -419,12 +434,14 @@ "Control how media is transcoded": "Controlar cómo se transcodifican los medios", "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.": "Controle la concurrencia de solicitudes para el procesamiento paralelo y añada retrasos entre solicitudes para evitar la limitación de velocidad del proveedor.", "Control what content is synced from the media server": "Controle qué contenido se sincroniza desde el servidor de medios", + "Cookies (Netscape format)": "Cookies (formato Netscape)", "Cookies File Path": "Ruta del archivo de cookies", "Copy Changes": "Copiar cambios", "Copy now": "Copiar ahora", "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.": "Copie el hash del archivo de la página de lanzamiento de GitHub para verificar que la descarga no haya sido manipulada.", "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.": "Copie el hash del archivo de la página de lanzamiento de GitHub para verificar que la descarga no haya sido manipulada.", "Copy the file hash from the GitHub release page.": "Copie el hash del archivo de la página de lanzamiento de GitHub.", + "Could not create smart channel": "No se pudo crear el canal inteligente", "Could not determine the record to update. Please close the modal and try again.": "No se pudo determinar el registro para actualizar. Cierra el modal y vuelve a intentarlo.", "Could not fetch release": "No se pudo obtener la versión", "Country": "País", @@ -439,11 +456,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "Cree redes en la sección Redes para crear canales pseudo-en vivo", "Create Plugin": "Crear complemento", "Create Token": "Crear ficha", + "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.": "Cree un \"canal inteligente\" personalizado a partir de los canales seleccionados. Se copiarán el título, el logotipo y la asignación de EPG de la fuente con la puntuación más alta. Todos los canales seleccionados se convierten en conmutación por error, ordenados por puntuación, y la lista de reproducción transmitirá la conmutación por error superior automáticamente.", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "Cree un alias de una lista de reproducción existente o una lista de reproducción personalizada para usar credenciales de API de Xtream diferentes, mientras sigue usando las mismas configuraciones subyacentes de canal, VOD y serie de la lista de reproducción vinculada.", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "Cree credenciales y asígnelas a su lista de reproducción para una autenticación sencilla. También se pueden utilizar para acceder a la API de Xtream para las listas de reproducción asignadas.", "Create live TV channels from your media server content": "Cree canales de TV en vivo a partir del contenido de su servidor de medios", "Create now": "Crear ahora", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "Crea listas de reproducción compuestas de canales de tus otras listas de reproducción. Dirígete a canales para agregar canales de forma masiva a tu lista de reproducción personalizada.", + "Create smart channel": "Crear canal inteligente", "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.": "Crea una nueva revisión de seguridad de los archivos actuales de este complemento. Úselo después de actualizar los archivos del complemento en el disco o después de una instalación fallida para reactivar el proceso de revisión sin volver a cargarlos.", "Credentials": "Cartas credenciales", "Current IDs": "ID actuales", @@ -471,6 +490,7 @@ "DVR Removed": "DVR eliminado", "DVR Status": "Estado del DVR", "DVR Sync Status": "Estado de sincronización del DVR", + "Daily": "A diario", "Dashboard": "Panel", "Data Ownership": "Propiedad de los datos", "Database: Execute Query": "Base de datos: ejecutar consulta", @@ -546,6 +566,7 @@ "Disable group channels now?": "¿Desactivar canales grupales ahora?", "Disable now": "Desactivar ahora", "Disable selected": "Desactivar seleccionado", + "Disable source channels": "Deshabilitar canales de origen", "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.": "Desactive la prueba de transmisión para todos los episodios de la serie seleccionada. Serán excluidos de los trabajos de sondeo de corrientes.", "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.": "Deshabilite el sondeo de transmisiones para las transmisiones VOD seleccionadas. Serán excluidos de los trabajos de sondeo de corrientes.", "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.": "Deshabilite el sondeo de flujo para los canales seleccionados. Serán excluidos de los trabajos de sondeo de corrientes.", @@ -589,6 +610,7 @@ "Duration (Seconds)": "Duración (segundos)", "Duration in HH:MM:SS format.": "Duración en formato HH:MM:SS.", "Duration in seconds.": "Duración en segundos.", + "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.": "Durante la recuperación programada o manual, los canales con estadísticas anteriores a esta se vuelven a sondear primero. Establezca en 0 para volver a sondear siempre. La acción \"Crear canal inteligente\" utiliza únicamente estadísticas existentes y no consulta esta configuración.", "EPG": "EPG", "EPG Cache is being generated": "Se está generando caché EPG", "EPG Cache is being generated for selected EPGs": "Se está generando caché de EPG para EPG seleccionadas", @@ -785,7 +807,13 @@ "Failover Channel": "Canal de conmutación por error", "Failover Channels": "Canales de conmutación por error", "Failover Playlist": "Lista de reproducción de conmutación por error", + "Failover Ranking": "Clasificación de conmutación por error", + "Failover Rescoring": "Restauración de conmutación por error", + "Failover groups will be re-scored in the background. You can keep using the app while this runs.": "Los grupos de conmutación por error se volverán a calificar en segundo plano. Puedes seguir usando la aplicación mientras se ejecuta.", + "Failover rescoring queued": "Recuperación de conmutación por error en cola", "Failovers": "Conmutaciones por error", + "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.": "Conmutaciones por error clasificadas por puntuación. Almacenado desde la última fusión o repuntuación: haga clic en \"Volver a puntuar ahora\" para volver a calcular con las estadísticas de transmisión actuales.", + "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.": "Las conmutaciones por error se volverán a calificar en segundo plano. Actualiza esta página en un momento para ver la clasificación actualizada.", "Fetch & Install": "Obtener e instalar", "Fetch & Update": "Buscar y actualizar", "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.": "¿Obtener ID para todas las series de listas de reproducción habilitadas? Si está deshabilitado, solo se buscará para las series de la lista de reproducción seleccionada.", @@ -993,6 +1021,7 @@ "John Doe, Jane Smith": "John Doe, Jane Smith", "Join us on Discord": "Únete a nosotros en Discord", "Keep cache permanently (disable expiry cleanup)": "Mantener el caché permanentemente (deshabilitar la limpieza de caducidad)", + "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.": "Mantiene los pedidos de conmutación por error sincronizados con la calidad de la transmisión actual. Afecta a tres lugares: la repuntuación programada (el intervalo a continuación), la acción por canal \"Volver a puntuar ahora\" y la acción masiva \"Crear canal inteligente\". El orden de prioridad de la combinación automática anterior se reutiliza para la puntuación real en los tres.", "Kinopoisk Rating Count": "Recuento de calificaciones de Kinopoisk", "Kinopoisk URL": "URL de cinepoisk", "Label": "Etiqueta", @@ -1019,6 +1048,7 @@ "Leave blank to keep the current password": "Déjelo en blanco para mantener la contraseña actual", "Leave blank to use the same provider as the primary account.": "Déjelo en blanco para utilizar el mismo proveedor que la cuenta principal.", "Leave empty for direct stream proxying": "Déjelo vacío para proxy de transmisión directa", + "Leave empty to copy the highest-scoring source's title.": "Déjelo vacío para copiar el título de la fuente con la puntuación más alta.", "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.": "Déjelo vacío para deshabilitar la generación de archivos .strm para VOD. Prioridad: VOD > Grupo > Global.", "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.": "Déjelo vacío para deshabilitar la generación de archivos .strm para series. Prioridad: Serie > Categoría > Global.", "Leave empty to remove": "Dejar vacío para eliminar", @@ -1091,6 +1121,7 @@ "MPAA rating classification.": "Clasificación de calificación MPAA.", "Main actors in the content.": "Actores principales del contenido.", "Make log files viewable": "Hacer visibles los archivos de registro", + "Make smart channel": "Hacer canal inteligente", "Manage": "Administrar", "Manage API Tokens": "Administrar tokens API", "Manage Assets": "Administrar activos", @@ -1182,6 +1213,7 @@ "Missing checksum": "Suma de comprobación faltante", "Missing credentials": "Credenciales faltantes", "Missing information": "Información faltante", + "Mixed playlists not supported": "No se admiten listas de reproducción mixtas", "Mode": "Modo", "Model": "Modelo", "Modified": "Modificado", @@ -1291,6 +1323,7 @@ "Not probed yet": "Aún no investigado", "Not set": "No establecido", "Not started": "No iniciado", + "Not yet probed — run a probe on this channel to capture stream details.": "Aún no sondeado: ejecute un sondeo en este canal para capturar los detalles de la transmisión.", "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.": "Nota: La calidad, el códec de vídeo, el audio y los marcadores de posición HDR requieren un sondeo de transmisión. Los canales/episodios que no hayan sido probados volverán a los valores manuales de la fuente de la lista de reproducción; algunos marcadores de posición pueden quedar vacíos.", "Notification Message": "Mensaje de notificación", "Notification Subject": "Asunto de la notificación", @@ -1310,6 +1343,7 @@ "Number of streams available for HDHR and Xtream API service (if using).": "Número de transmisiones disponibles para el servicio HDHR y Xtream API (si se usa).", "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).": "Número de transmisiones disponibles para esta lista de reproducción (solo se aplica a canales personalizados asignados a esta lista de reproducción personalizada).", "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.": "Número de transmisiones disponibles para este proveedor. Si se establece en un valor distinto de 0, evitará que se inicie cualquier transmisión si el número de transmisiones activas excede este valor.", + "Off": "Apagado", "Offline": "Desconectado", "Online": "En línea", "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.": "Solo se podrá acceder a los canales VOD de estos grupos. Déjelo vacío para permitir todos los grupos VOD.", @@ -1373,6 +1407,7 @@ "Parallel processing": "Procesamiento paralelo", "Password": "Contraseña", "Password for playlist access.": "Contraseña para acceder a la lista de reproducción.", + "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".": "Pegue el contenido de cookies.txt para transmisiones autenticadas (por ejemplo, solo para miembros de YouTube, con restricción de edad). Obtenga cookies utilizando una extensión del navegador como \"Obtener cookies.txt LOCALMENTE\".", "Path Preview": "Vista previa de ruta", "Path Validation Failed": "Error de validación de ruta", "Path structure (folders)": "Estructura de ruta (carpetas)", @@ -1382,6 +1417,7 @@ "Pending Review": "Revisión pendiente", "Pending Trust": "Confianza pendiente", "Performance": "Rendimiento", + "Periodic rescoring": "Repunte periódico", "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.": "Elimina permanentemente los archivos del complemento del servidor y elimina su registro, configuración e historial de ejecución. Esto no se puede deshacer.", "Permanently removes this install log entry. The plugin itself (if installed) is not affected.": "Elimina permanentemente esta entrada del registro de instalación. El complemento en sí (si está instalado) no se ve afectado.", "Permissions": "Permisos", @@ -1528,6 +1564,7 @@ "Probe failed": "Sonda fallida", "Probe now": "Sondear ahora", "Probe ran but returned no stream info": "La sonda se ejecutó pero no arrojó información de transmisión", + "Probe ran but returned no usable details.": "La sonda se ejecutó pero no arrojó detalles utilizables.", "Probe streams after sync": "Transmisiones de sonda después de la sincronización", "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Sondee los episodios de la serie seleccionada con ffprobe para recopilar metadatos de transmisión (códec, resolución, tasa de bits, HDR). Estos datos permiten nombrar la Guía de basura con detección basada en estadísticas de flujo.", "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Pruebe las transmisiones VOD seleccionadas con ffprobe para recopilar metadatos de la transmisión (códec, resolución, tasa de bits, HDR). Estos datos permiten nombrar la Guía de basura con detección basada en estadísticas de flujo.", @@ -1536,6 +1573,7 @@ "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Pruebe este VOD con ffprobe para recopilar metadatos de transmisión (códec, resolución, tasa de bits, HDR). Estos datos permiten nombrar la Guía de basura con detección basada en estadísticas de flujo.", "Probe timeout (seconds)": "Tiempo de espera de sondeo (segundos)", "Probed": "sondeado", + "Probing is disabled for this channel.": "El sondeo está deshabilitado para este canal.", "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.": "El sondeo está deshabilitado para este canal. Habilítelo en el formulario de edición del canal para permitir la recopilación de datos de la sonda.", "Probing started": "Se inició el sondeo", "Process": "Proceso", @@ -1605,6 +1643,7 @@ "Quality & Options": "Calidad y opciones", "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3": "Selector de calidad (best, worst, 720p, etc.) seguido de indicadores Streamlink opcionales. Ejemplo: best --hls-live-edge 3", "Queue Manager": "Administrador de colas", + "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.": "¿Poner en cola una recuperación de conmutación por error única para esta lista de reproducción? Los canales obsoletos se volverán a sondear (sujetos a la ventana de obsolescencia configurada) y las conmutaciones por error se reordenarán para que la fuente de mayor calidad ocupe el primer lugar.", "Queue a plugin action from the page header to create the first run.": "Ponga en cola una acción de complemento desde el encabezado de la página para crear la primera ejecución.", "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.": "Ponga en cola un escaneo desde el encabezado para generar la primera ejecución. Eso completará la Actividad en vivo, el Historial de ejecución y la pantalla de detalles de la ejecución.", "Queue reset": "Reinicio de cola", @@ -1613,6 +1652,7 @@ "Quick Actions": "Acciones rápidas", "Ran At": "corrió y", "Ran at": "corrió y", + "Ranked sources": "Fuentes clasificadas", "Rate Limit (requests/second)": "Límite de tasa (solicitudes/segundo)", "Rating": "Clasificación", "Rating (5 based)": "Calificación (basada en 5)", @@ -1620,7 +1660,10 @@ "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.": "Reactivar canales deshabilitados que se encuentren en vivo. Requiere que \"Escanear todos los canales\" esté activado.", "Re-enable live channels": "Reactivar canales en vivo", "Re-probe": "Volver a sondear", + "Re-probe channels older than (days)": "Vuelva a sondear canales con más de (días)", "Re-run this mapping everytime the EPG is synced?": "¿Volver a ejecutar este mapeo cada vez que se sincroniza la EPG?", + "Re-score failovers now": "Vuelva a calificar las conmutaciones por error ahora", + "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.": "Vuelva a calificar las conmutaciones por error de este canal con las estadísticas de transmisión actuales. Los canales obsoletos se pueden volver a sondear (sujeto a la ventana de obsolescencia de la lista de reproducción). El canal maestro nunca se modifica, solo cambia el orden de conmutación por error.", "Recall Memories": "Recuperar recuerdos", "Recent Plugin Installs": "Instalaciones recientes de complementos", "Recent Runs": "Ejecuciones recientes", @@ -1704,6 +1747,9 @@ "Required to send emails, if your provider requires authentication.": "Requerido para enviar correos electrónicos, si su proveedor requiere autenticación.", "Required to send emails.": "Requerido para enviar correos electrónicos.", "Rescan storage": "Volver a escanear el almacenamiento", + "Rescore": "volver a puntuar", + "Rescore now": "Resucitar ahora", + "Rescoring queued": "Recuperando en cola", "Reset": "Reiniciar", "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.": "Restablezca el estado de la EPG para que pueda procesarse nuevamente. Realice esta acción solo si tiene problemas con la sincronización de la EPG.", "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.": "Restablezca los resultados de Buscar y reemplazar a los valores predeterminados de EPG. Esto eliminará cualquier valor personalizado establecido en la columna seleccionada.", @@ -1802,6 +1848,7 @@ "Schedule Start Time": "Programar hora de inicio", "Schedule Type": "Tipo de horario", "Schedule Window": "Ventana de programación", + "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.": "Programe una nueva puntuación recurrente para cada grupo de conmutación por error en esta lista de reproducción. Los canales maestros nunca se promocionan ni reemplazan, solo cambios en el orden de clasificación de conmutación por error. Desactivado = volver a puntuar manualmente sólo mediante el botón \"Rescore ahora\".", "Schedule window is already one week": "La ventana de programación ya es de una semana.", "Scheduled": "Programado", "Scheduled Recordings": "Grabaciones programadas", @@ -2012,6 +2059,14 @@ "Simple authentication for playlist access.": "Autenticación simple para acceder a la lista de reproducción.", "Size": "Tamaño", "Skip channels without EPG ID": "Saltar canales sin ID de EPG", + "Smart": "Elegante", + "Smart channel": "canal inteligente", + "Smart channel created": "Canal inteligente creado", + "Smart channel without failovers won't stream.": "El canal inteligente sin conmutación por error no se transmitirá.", + "Smart channel — streams the highest-ranked failover automatically": "Canal inteligente: transmite automáticamente la conmutación por error mejor clasificada", + "Smart channels cannot be sources": "Los canales inteligentes no pueden ser fuentes", + "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.": "Los canales inteligentes deben crearse a partir de fuentes dentro de una única lista de reproducción. Limite su selección y vuelva a intentarlo.", + "Some channels were skipped": "Algunos canales fueron saltados", "Sort": "Clasificar", "Sort Alpha": "Ordenar alfa", "Sort Alpha Configs": "Ordenar configuraciones alfa", @@ -2499,6 +2554,7 @@ "View the EPG channel mapping jobs and progress here.": "Vea los trabajos y el progreso de mapeo de canales de EPG aquí.", "View/Update Unique Identifier": "Ver/actualizar identificador único", "Viewer ID": "ID de espectador", + "Virtual channel title": "Título del canal virtual", "Wait this many seconds after sync completes before triggering the library refresh": "Espere tantos segundos después de que se complete la sincronización antes de activar la actualización de la biblioteca", "Wait until a specific date/time before starting the broadcast.": "Espere hasta una fecha/hora específica antes de comenzar la transmisión.", "Warning": "Advertencia", @@ -2508,6 +2564,7 @@ "WebDAV Password": "Contraseña WebDAV", "WebDAV Username": "Nombre de usuario WebDAV", "WebSocket Connection Test": "Prueba de conexión WebSocket", + "Weekly": "Semanalmente", "Weight": "Peso", "Weight (for shuffle)": "Peso (para reproducción aleatoria)", "What does this plugin do?": "¿Qué hace este complemento?", @@ -2565,6 +2622,7 @@ "When enabled, the playlist will fetch items by category.": "Cuando está habilitado, la lista de reproducción buscará elementos por categoría.", "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.": "Cuando está habilitado, la lista de reproducción buscará elementos por categoría. Esto puede ralentizar el proceso de importación, pero puede ayudar con listas de reproducción más grandes cuyo tiempo de espera se agota al recuperar todos los elementos a la vez.", "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.": "Cuando está habilitado, el proxy intentará iniciar transmisiones incluso si se ha alcanzado el límite de conexión informado por el proveedor.", + "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.": "Cuando está habilitado, los canales de origen seleccionados se deshabilitarán después de conectarse como conmutación por error. Solo serán accesibles a través del nuevo canal inteligente.", "When enabled, the user will be prompted to set a new password before they can use the application.": "Cuando esté habilitado, se le pedirá al usuario que establezca una nueva contraseña antes de poder usar la aplicación.", "When enabled, there will be an additional navigation item (Logs) to view the log file content.": "Cuando esté habilitado, habrá un elemento de navegación adicional (Registros) para ver el contenido del archivo de registro.", "When enabled, this network will continuously broadcast content according to the schedule.": "Cuando esté habilitada, esta red transmitirá contenido continuamente según el horario.", @@ -2611,6 +2669,7 @@ "Yes, generate cache now": "Sí, generar caché ahora", "Yes, process now": "Sí, procesar ahora", "Yes, refresh now": "Sí, actualiza ahora", + "Yes, rescore now": "Sí, vuelve a puntuar ahora", "Yes, reset now": "Sí, restablecer ahora", "Yes, sync now": "Sí, sincroniza ahora", "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.": "Eres un útil asistente de IA integrado en el editor m3u. Ayuda a los usuarios a administrar listas de reproducción, datos EPG, transmisiones, canales y otras funciones. Sea conciso y preciso.", @@ -2632,6 +2691,7 @@ "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.": "Su clave API de TMDB (autenticación v3). Puede obtener uno gratis en themoviedb.org.", "Your m3u proxy API key": "Su clave API de proxy m3u", "Your preferences have been saved successfully.": "Tus preferencias se han guardado correctamente.", + "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.": "Su selección incluye uno o más canales inteligentes existentes; elija canales de proveedores sin procesar o elimine primero la marca de canal inteligente.", "aac": "acac", "and how to use it": "y como usarlo", "e.g. 2024": "p.ej. 2024", @@ -2679,4 +2739,4 @@ "veryfast": "muy rápido", "your-api-key-here": "tu-clave-api-aquí", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "Selector de formato yt-dlp seguido de indicadores opcionales. Ejemplo: bestvideo+bestaudio/best --no-playlist" -} \ No newline at end of file +} diff --git a/lang/fr.json b/lang/fr.json index 5fed7ed17..cfc95d694 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -16,17 +16,29 @@ "01:30:00": "01:30:00", "1": "1", "1-1000, higher = more preferred": "1-1000, plus élevé = plus préféré", - "10": "10", "10 based rating of the VOD content.": "Classement basé sur 10 du contenu VOD.", - "2": "2", - "3": "3", - "4": "4", - "5": "5", - "6": "6", - "7": "7", - "8": "8", + "2": "10", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "7": "6", + "8": "7", "8.7": "8.7", - "9": "9", + "9": "8", + "10": "9", + "11": "0", + "12": "1", + "13": "10", + "14": "2", + "15": "3", + "16": "4", + "17": "5", + "18": "6", + "19": "7", + "20": "8", + "21": "9", + ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.": ":count canaux intelligents ont été ignorés – les canaux intelligents ne peuvent pas être utilisés eux-mêmes comme basculements.", ":images images": ":images images", ":name installed": ":name installé", ":name updated": ":name mis à jour", @@ -35,6 +47,7 @@ ":uploaded uploaded · :cached cached": ":uploaded téléversé · :cached mis en cache", "A SHA-256 checksum is required to stage this update.": "Une somme de contrôle SHA-256 est requise pour effectuer cette mise à jour.", "A channel dedicated to classic movies from the golden age of cinema": "Une chaîne dédiée aux films classiques de l'âge d'or du cinéma", + "A custom channel was created with the selected sources attached as failovers, ranked by quality.": "Un canal personnalisé a été créé avec les sources sélectionnées attachées en tant que basculements, classées par qualité.", "A descriptive name for this post process.": "Un nom descriptif pour ce post-processus.", "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")": "Un nom descriptif pour ce profil (par exemple, \"720p Standard\", \"Twitch Stream\")", "A descriptive name for this stream file setting profile": "Un nom descriptif pour ce profil de paramètres de fichier de flux", @@ -75,6 +88,7 @@ "Add and manage authentication.": "Ajoutez et gérez l'authentification.", "Add any custom headers to include when streaming a channel/episode.": "Ajoutez tous les en-têtes personnalisés à inclure lors de la diffusion d’une chaîne/d’un épisode.", "Add as failover": "Ajouter comme basculement", + "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.": "Ajoutez au moins un canal de basculement ci-dessous : le canal intelligent prend son URL de flux à partir du basculement le mieux classé au moment du flux.", "Add auto-add rule": "Ajouter une règle d'ajout automatique", "Add available subtitle languages for this content.": "Ajoutez les langues de sous-titres disponibles pour ce contenu.", "Add backdrop/poster image URLs for this content.": "Ajoutez des URL d’images de toile de fond/d’affiche pour ce contenu.", @@ -215,6 +229,7 @@ "Auto-create groups/categories from TMDB genres": "Créez automatiquement des groupes/catégories à partir des genres TMDB", "Auto-lookup on metadata fetch": "Recherche automatique lors de la récupération des métadonnées", "Auto-regenerate Schedule": "Calendrier de régénération automatique", + "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.": "Diffuse automatiquement le basculement le mieux classé. Le champ URL est verrouillé lorsqu'il est activé. Chaînes personnalisées uniquement.", "Auto/Default": "Automatique/Par défaut", "Automated backups": "Sauvegardes automatisées", "Automatically assign sort number based on playlist order": "Attribuer automatiquement un numéro de tri en fonction de l'ordre de la liste de lecture", @@ -419,12 +434,14 @@ "Control how media is transcoded": "Contrôler la façon dont les médias sont transcodés", "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.": "Contrôlez la simultanéité des requêtes pour le traitement parallèle et ajoutez des délais entre les requêtes pour éviter la limitation de débit du fournisseur.", "Control what content is synced from the media server": "Contrôler quel contenu est synchronisé depuis le serveur multimédia", + "Cookies (Netscape format)": "Cookies (format Netscape)", "Cookies File Path": "Chemin du fichier de cookies", "Copy Changes": "Copier les modifications", "Copy now": "Copier maintenant", "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.": "Copiez le hachage du fichier depuis la page de version de GitHub pour vérifier que le téléchargement n'a pas été falsifié.", "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.": "Copiez le hachage du fichier depuis la page de version de GitHub pour vérifier que le téléchargement n'a pas été falsifié.", "Copy the file hash from the GitHub release page.": "Copiez le hachage du fichier depuis la page de version de GitHub.", + "Could not create smart channel": "Impossible de créer une chaîne intelligente", "Could not determine the record to update. Please close the modal and try again.": "Impossible de déterminer l'enregistrement à mettre à jour. Veuillez fermer le modal et réessayer.", "Could not fetch release": "Impossible de récupérer la version", "Country": "Pays", @@ -439,11 +456,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "Créez des réseaux dans la section Réseaux pour créer des chaînes pseudo-live", "Create Plugin": "Créer un plugin", "Create Token": "Créer un jeton", + "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.": "Créez un « canal intelligent » personnalisé à partir des canaux sélectionnés. Le titre, le logo et le mappage EPG de la source ayant obtenu le score le plus élevé seront copiés. Tous les canaux sélectionnés deviennent des basculements, triés par score, et la playlist diffusera automatiquement le basculement le plus important.", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "Créez un alias d'une playlist existante ou d'une playlist personnalisée pour utiliser des informations d'identification d'API Xtream différentes, tout en utilisant les mêmes configurations sous-jacentes de chaîne, de VOD et de série de la playlist liée.", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "Créez des informations d'identification et attribuez-les à votre liste de lecture pour une authentification simple. Ils peuvent également être utilisés pour accéder à l'API Xtream pour les playlists attribuées.", "Create live TV channels from your media server content": "Créez des chaînes de télévision en direct à partir du contenu de votre serveur multimédia", "Create now": "Créer maintenant", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "Créez des listes de lecture composées de chaînes de vos autres listes de lecture. Accédez aux chaînes pour ajouter en masse des chaînes à votre liste de lecture personnalisée.", + "Create smart channel": "Créer un canal intelligent", "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.": "Crée un nouvel examen de sécurité des fichiers actuels de ce plugin. Utilisez-le après la mise à jour des fichiers du plugin sur le disque ou après un échec d'installation pour relancer le processus de révision sans nouveau téléchargement.", "Credentials": "Informations d'identification", "Current IDs": "Identifiants actuels", @@ -471,6 +490,7 @@ "DVR Removed": "DVR supprimé", "DVR Status": "Statut du DVR", "DVR Sync Status": "État de synchronisation du DVR", + "Daily": "Tous les jours", "Dashboard": "Tableau de bord", "Data Ownership": "Propriété des données", "Database: Execute Query": "Base de données : exécuter la requête", @@ -546,6 +566,7 @@ "Disable group channels now?": "Désactiver les chaînes de groupe maintenant ?", "Disable now": "Désactiver maintenant", "Disable selected": "Désactiver la sélection", + "Disable source channels": "Désactiver les chaînes sources", "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.": "Désactivez la détection de flux pour tous les épisodes de la série sélectionnée. Ils seront exclus des tâches de sondage de flux.", "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.": "Désactivez la détection de flux pour les flux VOD sélectionnés. Ils seront exclus des tâches de sondage de flux.", "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.": "Désactivez la détection de flux pour les canaux sélectionnés. Ils seront exclus des tâches de sondage de flux.", @@ -589,6 +610,7 @@ "Duration (Seconds)": "Durée (secondes)", "Duration in HH:MM:SS format.": "Durée au format HH:MM:SS.", "Duration in seconds.": "Durée en secondes.", + "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.": "Lors d'une nouvelle évaluation programmée ou manuelle, les chaînes dont les statistiques sont plus anciennes sont d'abord réévaluées. Réglez-le sur 0 pour toujours re-sonder. L'action « Créer une chaîne intelligente » utilise uniquement les statistiques existantes et ne consulte pas ce paramètre.", "EPG": "EPG", "EPG Cache is being generated": "Le cache EPG est en cours de génération", "EPG Cache is being generated for selected EPGs": "Le cache EPG est généré pour les EPG sélectionnés", @@ -785,7 +807,13 @@ "Failover Channel": "Canal de basculement", "Failover Channels": "Canaux de basculement", "Failover Playlist": "Liste de lecture de basculement", + "Failover Ranking": "Classement de basculement", + "Failover Rescoring": "Réévaluation du basculement", + "Failover groups will be re-scored in the background. You can keep using the app while this runs.": "Les groupes de basculement seront réévalués en arrière-plan. Vous pouvez continuer à utiliser l'application pendant son exécution.", + "Failover rescoring queued": "Réévaluation du basculement en file d'attente", "Failovers": "Basculements", + "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.": "Basculements classés par score. Stocké à partir de la dernière fusion ou nouvelle évaluation : cliquez sur \"Rescore maintenant\" pour recalculer par rapport aux statistiques de flux actuelles.", + "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.": "Les basculements seront réévalués en arrière-plan. Actualisez cette page dans un instant pour voir le classement mis à jour.", "Fetch & Install": "Récupérer et installer", "Fetch & Update": "Récupérer et mettre à jour", "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.": "Récupérer les identifiants de toutes les séries de playlists activées ? S'il est désactivé, il ne sera récupéré que pour les séries de la liste de lecture sélectionnée.", @@ -993,6 +1021,7 @@ "John Doe, Jane Smith": "John Doe, Jane Smith", "Join us on Discord": "Rejoignez-nous sur Discord", "Keep cache permanently (disable expiry cleanup)": "Conserver le cache en permanence (désactiver le nettoyage à l'expiration)", + "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.": "Maintient l’ordre de basculement en synchronisation avec la qualité actuelle du flux. Affecte trois emplacements : la nouvelle évaluation planifiée (l'intervalle ci-dessous), l'action « Réenregistrer maintenant » par canal et l'action groupée « Créer une chaîne intelligente ». L'ordre de priorité sous Fusion automatique ci-dessus est réutilisé pour le score réel dans les trois.", "Kinopoisk Rating Count": "Nombre de notes Kinopoisk", "Kinopoisk URL": "URL du Kinopoisk", "Label": "Étiquette", @@ -1019,6 +1048,7 @@ "Leave blank to keep the current password": "Laisser vide pour conserver le mot de passe actuel", "Leave blank to use the same provider as the primary account.": "Laissez vide pour utiliser le même fournisseur que le compte principal.", "Leave empty for direct stream proxying": "Laisser vide pour le proxy de flux direct", + "Leave empty to copy the highest-scoring source's title.": "Laissez vide pour copier le titre de la source ayant obtenu le score le plus élevé.", "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.": "Laissez vide pour désactiver la génération de fichiers .strm pour la VOD. Priorité : VOD > Groupe > Global.", "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.": "Laissez vide pour désactiver la génération de fichiers .strm pour les séries. Priorité : Série > Catégorie > Global.", "Leave empty to remove": "Laisser vide pour supprimer", @@ -1091,6 +1121,7 @@ "MPAA rating classification.": "Classement MPAA.", "Main actors in the content.": "Principaux acteurs du contenu.", "Make log files viewable": "Rendre les fichiers journaux visibles", + "Make smart channel": "Créer une chaîne intelligente", "Manage": "Gérer", "Manage API Tokens": "Gérer les jetons API", "Manage Assets": "Gérer les actifs", @@ -1182,6 +1213,7 @@ "Missing checksum": "Somme de contrôle manquante", "Missing credentials": "Informations d'identification manquantes", "Missing information": "Informations manquantes", + "Mixed playlists not supported": "Listes de lecture mixtes non prises en charge", "Mode": "Mode", "Model": "Modèle", "Modified": "Modifié", @@ -1291,6 +1323,7 @@ "Not probed yet": "Pas encore sondé", "Not set": "Non défini", "Not started": "Pas commencé", + "Not yet probed — run a probe on this channel to capture stream details.": "Pas encore sondé : exécutez une sonde sur ce canal pour capturer les détails du flux.", "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.": "Remarque : Les espaces réservés pour la qualité, le codec vidéo, l'audio et le HDR nécessitent une analyse de flux. Les chaînes/épisodes qui n'ont pas été sondés reviendront aux valeurs manuelles de la source de la playlist — certains espaces réservés peuvent devenir vides.", "Notification Message": "Message de notification", "Notification Subject": "Objet de la notification", @@ -1310,6 +1343,7 @@ "Number of streams available for HDHR and Xtream API service (if using).": "Nombre de flux disponibles pour le service HDHR et Xtream API (le cas échéant).", "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).": "Nombre de flux disponibles pour cette playlist (s'applique uniquement aux chaînes personnalisées attribuées à cette playlist personnalisée).", "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.": "Nombre de flux disponibles pour ce fournisseur. S'il est défini sur une valeur autre que 0, empêchera tout flux de démarrer si le nombre de flux actifs dépasse cette valeur.", + "Off": "Désactivé", "Offline": "Hors ligne", "Online": "En ligne", "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.": "Seules les chaînes VOD de ces groupes seront accessibles. Laissez vide pour autoriser tous les groupes VOD.", @@ -1373,6 +1407,7 @@ "Parallel processing": "Traitement parallèle", "Password": "Mot de passe", "Password for playlist access.": "Mot de passe pour accéder à la playlist.", + "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".": "Collez le contenu cookies.txt pour les flux authentifiés (par exemple, réservés aux membres YouTube, limités par âge). Obtenez des cookies en utilisant une extension de navigateur telle que « Obtenir cookies.txt LOCALEMENT ».", "Path Preview": "Aperçu du chemin", "Path Validation Failed": "Échec de la validation du chemin", "Path structure (folders)": "Structure du chemin (dossiers)", @@ -1382,6 +1417,7 @@ "Pending Review": "En attente d'examen", "Pending Trust": "En attente de confiance", "Performance": "Performance", + "Periodic rescoring": "Nouvelle notation périodique", "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.": "Supprime définitivement les fichiers du plugin du serveur et supprime son enregistrement de registre, ses paramètres et son historique d'exécution. Cela ne peut pas être annulé.", "Permanently removes this install log entry. The plugin itself (if installed) is not affected.": "Supprime définitivement cette entrée du journal d’installation. Le plugin lui-même (s'il est installé) n'est pas affecté.", "Permissions": "Autorisations", @@ -1528,6 +1564,7 @@ "Probe failed": "La sonde a échoué", "Probe now": "Sondez maintenant", "Probe ran but returned no stream info": "La sonde s'est exécutée mais n'a renvoyé aucune information sur le flux", + "Probe ran but returned no usable details.": "La sonde s'est exécutée mais n'a renvoyé aucun détail utilisable.", "Probe streams after sync": "Sonder les flux après la synchronisation", "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Sondez les épisodes de la série sélectionnée avec ffprobe pour collecter les métadonnées du flux (codec, résolution, débit, HDR). Ces données permettent la dénomination du Trash Guide avec une détection basée sur les statistiques de flux.", "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Sondez les flux VOD sélectionnés avec ffprobe pour collecter les métadonnées du flux (codec, résolution, débit binaire, HDR). Ces données permettent la dénomination du Trash Guide avec une détection basée sur les statistiques de flux.", @@ -1536,6 +1573,7 @@ "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "Sondez cette VOD avec ffprobe pour collecter les métadonnées du flux (codec, résolution, débit binaire, HDR). Ces données permettent la dénomination du Trash Guide avec une détection basée sur les statistiques de flux.", "Probe timeout (seconds)": "Délai de détection (secondes)", "Probed": "Sondé", + "Probing is disabled for this channel.": "Le sondage est désactivé pour ce canal.", "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.": "Le sondage est désactivé pour ce canal. Activez-le dans le formulaire d'édition du canal pour autoriser la collecte de données de sonde.", "Probing started": "Le sondage a commencé", "Process": "Processus", @@ -1605,6 +1643,7 @@ "Quality & Options": "Qualité et options", "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3": "Sélecteur de qualité (best, worst, 720p, etc.) suivi d'indicateurs Streamlink facultatifs. Exemple : best --hls-live-edge 3", "Queue Manager": "Gestionnaire de files d'attente", + "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.": "Mettre en file d'attente une restauration de basculement unique pour cette playlist ? Les canaux obsolètes seront à nouveau sondés (sous réserve de la fenêtre d'obsolescence configurée) et les basculements seront triés à nouveau afin que la source de la plus haute qualité soit placée en premier.", "Queue a plugin action from the page header to create the first run.": "Mettez en file d'attente une action de plugin à partir de l'en-tête de la page pour créer la première exécution.", "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.": "Mettez en file d'attente une analyse à partir de l'en-tête pour générer la première exécution. Cela remplira l'activité en direct, l'historique des exécutions et l'écran de détails de l'exécution.", "Queue reset": "Réinitialisation de la file d'attente", @@ -1613,6 +1652,7 @@ "Quick Actions": "Actions rapides", "Ran At": "Ran et", "Ran at": "J'ai couru et", + "Ranked sources": "Sources classées", "Rate Limit (requests/second)": "Limite de débit (requêtes/seconde)", "Rating": "Notation", "Rating (5 based)": "Note (sur base 5)", @@ -1620,7 +1660,10 @@ "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.": "Réactiver les chaînes désactivées qui s'avèrent être en direct. Nécessite que \"Scanner toutes les chaînes\" soit activé.", "Re-enable live channels": "Réactiver les chaînes en direct", "Re-probe": "Re-sondez", + "Re-probe channels older than (days)": "Réinterroger les canaux datant de plus de (jours)", "Re-run this mapping everytime the EPG is synced?": "Réexécuter ce mappage à chaque fois que l'EPG est synchronisé ?", + "Re-score failovers now": "Réévaluer les basculements maintenant", + "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.": "Réévaluez les basculements de cette chaîne par rapport aux statistiques de flux actuelles. Les chaînes obsolètes peuvent être à nouveau sondées (sous réserve de la fenêtre d'obsolescence de la liste de lecture). Le canal maître n'est jamais modifié : seul l'ordre de basculement change.", "Recall Memories": "Rappeler des souvenirs", "Recent Plugin Installs": "Installations récentes de plugins", "Recent Runs": "Exécutions récentes", @@ -1704,6 +1747,9 @@ "Required to send emails, if your provider requires authentication.": "Obligatoire pour envoyer des e-mails, si votre fournisseur exige une authentification.", "Required to send emails.": "Obligatoire pour envoyer des e-mails.", "Rescan storage": "Réanalyser le stockage", + "Rescore": "Noter", + "Rescore now": "Recommencez maintenant", + "Rescoring queued": "Réévaluation en file d'attente", "Reset": "Réinitialiser", "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.": "Réinitialisez le statut de l'EPG afin qu'il puisse être traité à nouveau. N'effectuez cette action que si vous rencontrez des problèmes avec la synchronisation EPG.", "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.": "Réinitialisez les résultats Rechercher et remplacer aux valeurs par défaut de l'EPG. Cela supprimera toutes les valeurs personnalisées définies dans la colonne sélectionnée.", @@ -1802,6 +1848,7 @@ "Schedule Start Time": "Programmer l'heure de début", "Schedule Type": "Type d'horaire", "Schedule Window": "Fenêtre de planification", + "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.": "Planifiez une restauration récurrente pour chaque groupe de basculement de cette liste de lecture. Les canaux principaux ne sont jamais promus ni remplacés ; seuls les changements dans l'ordre de tri des basculements sont effectués. Off = refaire le score manuellement uniquement via le bouton \"Rescore maintenant\".", "Schedule window is already one week": "La fenêtre de planification est déjà d'une semaine", "Scheduled": "Programmé", "Scheduled Recordings": "Enregistrements programmés", @@ -2012,6 +2059,14 @@ "Simple authentication for playlist access.": "Authentification simple pour l'accès à la playlist.", "Size": "Taille", "Skip channels without EPG ID": "Ignorer les chaînes sans identifiant EPG", + "Smart": "Intelligent", + "Smart channel": "Canal intelligent", + "Smart channel created": "Chaîne intelligente créée", + "Smart channel without failovers won't stream.": "Le canal intelligent sans basculement ne diffusera pas.", + "Smart channel — streams the highest-ranked failover automatically": "Canal intelligent : diffuse automatiquement le basculement le mieux classé", + "Smart channels cannot be sources": "Les chaînes intelligentes ne peuvent pas être des sources", + "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.": "Les chaînes intelligentes doivent être créées à partir de sources au sein d’une seule playlist. Affinez votre sélection et réessayez.", + "Some channels were skipped": "Certaines chaînes ont été ignorées", "Sort": "Trier", "Sort Alpha": "Trier Alpha", "Sort Alpha Configs": "Trier les configurations Alpha", @@ -2193,6 +2248,8 @@ "The database table to query.": "La table de base de données à interroger.", "The default transcoding profile used by the in-app player for Live content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "Le profil de transcodage par défaut utilisé par le lecteur intégré à l'application pour le contenu Live. Un profil de flux par chaîne (s'il est défini) est prioritaire. Laissez vide pour désactiver le transcodage (certains flux peuvent ne pas être lisibles dans le lecteur).", "The default transcoding profile used by the in-app player for VOD/Series content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "Le profil de transcodage par défaut utilisé par le lecteur intégré à l'application pour le contenu VOD/Séries. Un profil de flux par chaîne (s'il est défini) est prioritaire. Laissez vide pour désactiver le transcodage (certains flux peuvent ne pas être lisibles dans le lecteur).", + "The default transcoding profile used for the in-app player for Live content. Leave empty to disable transcoding (some streams may not be playable in the player).": "Le profil de transcodage par défaut utilisé pour le lecteur intégré à l'application pour le contenu Live. Laissez vide pour désactiver le transcodage (certains flux peuvent ne pas être lisibles dans le lecteur).", + "The default transcoding profile used for the in-app player for VOD/Series content. Leave empty to disable transcoding (some streams may not be playable in the player).": "The default transcoding profile used for the in-app player for VOD/Series content. Laissez vide pour désactiver le transcodage (certains flux peuvent ne pas être lisibles dans le lecteur).", "The event that will trigger this post process.": "L'événement qui déclenchera ce post-processus.", "The event that will trigger this post process. \"VOD/Series Stream Files Synced\" fires after the respective .strm file sync completes (requires .strm sync to be enabled on the playlist).": "L'événement qui déclenchera ce post-processus. « Fichiers de flux VOD/série synchronisés » se déclenche une fois la synchronisation du fichier .strm respective terminée (nécessite que la synchronisation .strm soit activée sur la liste de lecture).", "The file extension of the VOD container (e.g., mp4, mkv, etc.).": "L'extension de fichier du conteneur VOD (par exemple, mp4, mkv, etc.).", @@ -2499,6 +2556,7 @@ "View the EPG channel mapping jobs and progress here.": "Consultez les tâches et les progrès du mappage des canaux EPG ici.", "View/Update Unique Identifier": "Afficher/mettre à jour l'identifiant unique", "Viewer ID": "ID du spectateur", + "Virtual channel title": "Titre de la chaîne virtuelle", "Wait this many seconds after sync completes before triggering the library refresh": "Attendez autant de secondes après la fin de la synchronisation avant de déclencher l'actualisation de la bibliothèque", "Wait until a specific date/time before starting the broadcast.": "Attendez une date/heure précise avant de démarrer la diffusion.", "Warning": "Avertissement", @@ -2508,6 +2566,7 @@ "WebDAV Password": "Mot de passe WebDAV", "WebDAV Username": "Nom d'utilisateur WebDAV", "WebSocket Connection Test": "Test de connexion WebSocket", + "Weekly": "Hebdomadaire", "Weight": "Poids", "Weight (for shuffle)": "Poids (pour la lecture aléatoire)", "What does this plugin do?": "Que fait ce plugin ?", @@ -2565,6 +2624,7 @@ "When enabled, the playlist will fetch items by category.": "Lorsqu'elle est activée, la liste de lecture récupérera les éléments par catégorie.", "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.": "Lorsqu'elle est activée, la liste de lecture récupérera les éléments par catégorie. Cela peut ralentir le processus d'importation, mais peut aider avec des listes de lecture plus volumineuses qui expirent lors de la récupération de tous les éléments en même temps.", "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.": "Lorsqu'il est activé, le proxy tentera de démarrer des flux même si la limite de connexion signalée par le fournisseur a été atteinte.", + "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.": "Lorsqu'ils sont activés, les canaux sources sélectionnés seront désactivés après avoir été attachés en tant que basculements. Ils ne seront accessibles que via le nouveau canal intelligent.", "When enabled, the user will be prompted to set a new password before they can use the application.": "Lorsqu'il est activé, l'utilisateur sera invité à définir un nouveau mot de passe avant de pouvoir utiliser l'application.", "When enabled, there will be an additional navigation item (Logs) to view the log file content.": "Lorsqu'il est activé, il y aura un élément de navigation supplémentaire (Journaux) pour afficher le contenu du fichier journal.", "When enabled, this network will continuously broadcast content according to the schedule.": "Lorsqu'il est activé, ce réseau diffusera en continu du contenu selon le calendrier.", @@ -2611,6 +2671,7 @@ "Yes, generate cache now": "Oui, générez le cache maintenant", "Yes, process now": "Oui, procédez maintenant", "Yes, refresh now": "Oui, actualisez maintenant", + "Yes, rescore now": "Oui, recommence maintenant", "Yes, reset now": "Oui, réinitialisez maintenant", "Yes, sync now": "Oui, synchronise maintenant", "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.": "Vous êtes un assistant IA utile intégré à l'éditeur m3u. Vous aidez les utilisateurs à gérer les listes de lecture, les données EPG, les flux, les chaînes et d'autres fonctionnalités. Soyez concis et précis.", @@ -2632,6 +2693,7 @@ "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.": "Votre clé API TMDB (authentification v3). Vous pouvez en obtenir un gratuitement sur themovedb.org.", "Your m3u proxy API key": "Votre clé API proxy m3u", "Your preferences have been saved successfully.": "Vos préférences ont été enregistrées avec succès.", + "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.": "Votre sélection inclut un ou plusieurs canaux intelligents existants : choisissez plutôt les canaux de fournisseur bruts ou supprimez d'abord l'indicateur de canal intelligent.", "aac": "aaa", "and how to use it": "et comment l'utiliser", "e.g. 2024": "par ex. 2024", @@ -2679,4 +2741,4 @@ "veryfast": "très rapide", "your-api-key-here": "votre-clé-api-ici", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "sélecteur de format yt-dlp suivi d'indicateurs facultatifs. Exemple : bestvideo+bestaudio/best --no-playlist" -} \ No newline at end of file +} diff --git a/lang/zh_CN.json b/lang/zh_CN.json index 9a0486e76..ff602bd6d 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -16,17 +16,29 @@ "01:30:00": "01:30:00", "1": "1", "1-1000, higher = more preferred": "1-1000,数字越大优先级越高", - "10": "10", "10 based rating of the VOD content.": "为 VOD 内容添加最高为 10 星的评价", - "2": "2", - "3": "3", - "4": "4", - "5": "5", - "6": "6", - "7": "7", - "8": "8", + "2": "10", + "3": "2", + "4": "3", + "5": "4", + "6": "5", + "7": "6", + "8": "7", "8.7": "8.7", - "9": "9", + "9": "8", + "10": "9", + "11": "0", + "12": "1", + "13": "10", + "14": "2", + "15": "3", + "16": "4", + "17": "5", + "18": "6", + "19": "7", + "20": "8", + "21": "9", + ":count smart channel(s) were skipped — smart channels can't be used as failovers themselves.": ":count 个智能通道被跳过 — 智能通道本身不能用作故障转移。", ":images images": ":images 图片", ":name installed": ":name 已安装", ":name updated": ":name 已更新", @@ -35,6 +47,7 @@ ":uploaded uploaded · :cached cached": ":uploaded 已上传 · :cached 已缓存", "A SHA-256 checksum is required to stage this update.": "执行此更新需要 SHA-256 校验。", "A channel dedicated to classic movies from the golden age of cinema": "专门播放电影黄金时代经典影片的频道", + "A custom channel was created with the selected sources attached as failovers, ranked by quality.": "创建了一个自定义通道,并将选定的源附加为故障转移,并按质量排名。", "A descriptive name for this post process.": "后处理的名称。", "A descriptive name for this profile (e.g., \"720p Standard\", \"Twitch Stream\")": "此配置文件的描述性名称(例如“720p 标准”、“Twitch Stream”)", "A descriptive name for this stream file setting profile": "流文件的配置文件的名称", @@ -75,6 +88,7 @@ "Add and manage authentication.": "添加或管理身份验证。", "Add any custom headers to include when streaming a channel/episode.": "在流/剧集时要包含的自定义标头。", "Add as failover": "添加故障转移", + "Add at least one failover channel below — the smart channel takes its stream URL from the highest-ranked failover at stream time.": "在下面添加至少一个故障转移通道 - 智能通道从流传输时排名最高的故障转移中获取其流 URL。", "Add auto-add rule": "添加自动添加的规则", "Add available subtitle languages for this content.": "为此内容添加可用的字幕语言。", "Add backdrop/poster image URLs for this content.": "为此内容添加背景图片或海报图片的URL。", @@ -215,6 +229,7 @@ "Auto-create groups/categories from TMDB genres": "根据 TMDB 类型自动创建组/类别", "Auto-lookup on metadata fetch": "获取元数据时自动查询", "Auto-regenerate Schedule": "自动重新生成计划表", + "Auto-streams the highest-ranked failover. URL field is locked while on. Custom channels only.": "自动流式传输排名最高的故障转移。 URL 字段在打开时被锁定。仅限自定义频道。", "Auto/Default": "自动/默认", "Automated backups": "自动备份", "Automatically assign sort number based on playlist order": "根据播放列表顺序自动分配序号", @@ -419,12 +434,14 @@ "Control how media is transcoded": "控制媒体转码方式", "Control request concurrency for parallel processing and add delays between requests to avoid provider rate limiting.": "控制请求的并发,在请求之间添加延迟以避免提供商限速。", "Control what content is synced from the media server": "控制从媒体服务器同步哪些内容", + "Cookies (Netscape format)": "Cookie(Netscape 格式)", "Cookies File Path": "Cookie 文件路径", "Copy Changes": "复制更改", "Copy now": "立即复制", "Copy the file hash from the GitHub release page to verify the download hasn't been tampered with.": "从 GitHub 发布页面复制文件哈希值,以验证下载内容未被篡改。", "Copy the file hash from the GitHub release page to verify the download hasn\\'t been tampered with.": "从 GitHub 发布页面复制文件哈希值,以验证下载内容未被篡改。", "Copy the file hash from the GitHub release page.": "从 GitHub 发布页面复制文件哈希值。", + "Could not create smart channel": "无法创建智能频道", "Could not determine the record to update. Please close the modal and try again.": "无法确定要更新的记录。请关闭弹窗并重试。", "Could not fetch release": "无法获取版本", "Country": "国家", @@ -439,11 +456,13 @@ "Create Networks in the Networks section to build pseudo-live channels": "在虚拟频道页面创建虚拟频道,以构建伪直播频道", "Create Plugin": "创建插件", "Create Token": "创建令牌", + "Create a custom \"smart channel\" from the selected channels. The highest-scoring source's title, logo, and EPG mapping will be copied. All selected channels become failovers, sorted by score, and the playlist will stream the top failover automatically.": "从所选渠道创建自定义“智能渠道”。得分最高的来源的标题、徽标和 EPG 映射将被复制。所有选定的频道都会成为故障转移,并按分数排序,并且播放列表将自动流式传输顶部的故障转移。", "Create an alias of an existing playlist or custom playlist to use a different Xtream API credentials, while still using the same underlying Channel, VOD and Series configurations of the linked playlist.": "为现有播放列表或自定义播放列表创建别名,以便使用不同的 Xtream API 凭据,同时仍使用所链接播放列表的底层频道、VOD 和剧集配置。", "Create credentials and assign them to your Playlist for simple authentication. They can also be used to access the Xtream API for the assigned Playlists.": "创建凭据并将其分配给您的播放列表进行简单身份验证。它们也可用于访问所分配播放列表的 Xtream API。", "Create live TV channels from your media server content": "利用媒体服务器内容创建直播电视频道", "Create now": "立即创建", "Create playlists composed of channels from your other playlists. Head to channels to bulk add channels to your custom playlist.": "创建由其他列表中的频道组成的播放列表。前往频道页面可批量将频道添加到您的自定义播放列表。", + "Create smart channel": "打造智能频道", "Creates a new security review of this plugin\\'s current files. Use this after updating plugin files on disk or after a failed install to re-trigger the review process without re-uploading.": "对该插件当前文件进行新的安全审查。在更新磁盘文件或安装失败后使用此功能,可重新触发审核流程而无需重新上传。", "Credentials": "凭据", "Current IDs": "当前 ID", @@ -471,6 +490,7 @@ "DVR Removed": "DVR 已移除", "DVR Status": "DVR 状态", "DVR Sync Status": "DVR 同步状态", + "Daily": "日常的", "Dashboard": "仪表板", "Data Ownership": "数据所有权", "Database: Execute Query": "数据库:执行查询", @@ -546,6 +566,7 @@ "Disable group channels now?": "立即禁用频道分组吗?", "Disable now": "立即禁用", "Disable selected": "禁用所选内容", + "Disable source channels": "禁用源通道", "Disable stream probing for all episodes of the selected series. They will be excluded from stream probing jobs.": "禁用对所选系列的所有剧集的流探测。他们将被排除在流探测作业之外。", "Disable stream probing for the selected VOD streams. They will be excluded from stream probing jobs.": "禁用对选定 VOD 流的流探测。他们将被排除在流探测作业之外。", "Disable stream probing for the selected channels. They will be excluded from stream probing jobs.": "禁用所选频道的流检测。这些频道将从流检测任务中排除。", @@ -589,6 +610,7 @@ "Duration (Seconds)": "时长(秒)", "Duration in HH:MM:SS format.": "HH:MM:SS 格式的时长。", "Duration in seconds.": "以秒为单位的时长。", + "During scheduled or manual rescoring, channels with stats older than this are re-probed first. Set to 0 to always re-probe. The \"Make smart channel\" action uses existing stats only and does not consult this setting.": "在计划或手动重新评分期间,首先会重新探测统计数据早于此的通道。设置为 0 始终重新探测。 “创建智能频道”操作仅使用现有统计数据,不参考此设置。", "EPG": "EPG", "EPG Cache is being generated": "正在生成 EPG 缓存", "EPG Cache is being generated for selected EPGs": "正在为选择的 EPG 生成缓存", @@ -785,7 +807,13 @@ "Failover Channel": "故障转移频道", "Failover Channels": "故障转移频道", "Failover Playlist": "故障转移播放列表", + "Failover Ranking": "故障转移排名", + "Failover Rescoring": "故障转移重新评分", + "Failover groups will be re-scored in the background. You can keep using the app while this runs.": "故障转移组将在后台重新评分。运行期间您可以继续使用该应用程序。", + "Failover rescoring queued": "故障转移重新评分已排队", "Failovers": "故障转移", + "Failovers ranked by score. Stored from the last merge or rescore — click \"Rescore now\" to recalculate against current stream stats.": "故障转移按分数排名。从上次合并或重新评分中存储 - 单击“立即重新评分”以根据当前流统计信息重新计算。", + "Failovers will be re-scored in the background. Refresh this page in a moment to see the updated ranking.": "故障转移将在后台重新评分。稍后刷新此页面即可查看更新后的排名。", "Fetch & Install": "获取并安装", "Fetch & Update": "获取并更新", "Fetch IDs for all enabled Playlist Series? If disabled, it will only be fetched for Series of the selected Playlist.": "获取所有已启用播放列表剧集的 ID 吗?如果禁用,则仅获取所选播放列表的剧集 ID。", @@ -993,6 +1021,7 @@ "John Doe, Jane Smith": "约翰·多伊 (John Doe)、简·史密斯 (Jane Smith)", "Join us on Discord": "加入我们的 Discord", "Keep cache permanently (disable expiry cleanup)": "永久保留缓存(禁用过期清理)", + "Keeps failover ordering in sync with current stream quality. Affects three places: scheduled rescoring (the interval below), the per-channel \"Rescore now\" action, and the \"Make smart channel\" bulk action. The Priority Order under Auto-Merge above is reused for the actual scoring in all three.": "使故障转移顺序与当前流质量保持同步。影响三个地方:计划重新评分(下面的间隔)、每个通道的“立即重新评分”操作和“创建智能通道”批量操作。上面自动合并下的优先顺序将重新用于所有三个项目的实际评分。", "Kinopoisk Rating Count": "Kinopoisk 评分人数", "Kinopoisk URL": "Kinopoisk URL", "Label": "标签", @@ -1019,6 +1048,7 @@ "Leave blank to keep the current password": "留空以保留当前密码", "Leave blank to use the same provider as the primary account.": "留空则使用与主账户相同的提供商。", "Leave empty for direct stream proxying": "留空则进行直接串流代理", + "Leave empty to copy the highest-scoring source's title.": "留空以复制得分最高的来源的标题。", "Leave empty to disable .strm file generation for VOD. Priority: VOD > Group > Global.": "留空则禁用 VOD 的 .strm 文件生成。优先级:VOD > 分组 > 全局。", "Leave empty to disable .strm file generation for series. Priority: Series > Category > Global.": "留空则禁用剧集的 .strm 文件生成。优先级:剧集 > 分类 > 全局。", "Leave empty to remove": "留空则清除", @@ -1091,6 +1121,7 @@ "MPAA rating classification.": "MPAA 评级分类。", "Main actors in the content.": "内容中的主要演员。", "Make log files viewable": "使日志文件可见", + "Make smart channel": "打造智慧渠道", "Manage": "管理", "Manage API Tokens": "管理 API 令牌", "Manage Assets": "管理资源", @@ -1182,6 +1213,7 @@ "Missing checksum": "缺少校验和", "Missing credentials": "缺少凭据", "Missing information": "缺少信息", + "Mixed playlists not supported": "不支持混合播放列表", "Mode": "模式", "Model": "模型", "Modified": "已修改", @@ -1291,6 +1323,7 @@ "Not probed yet": "尚未探查", "Not set": "未设置", "Not started": "未开始", + "Not yet probed — run a probe on this channel to capture stream details.": "尚未探测 — 在此通道上运行探测以捕获流详细信息。", "Note: Quality, video codec, audio and HDR placeholders require stream probing. Channels/episodes that have not been probed will fall back to manual values from the playlist source — some placeholders may render empty.": "注意:质量、视频编解码器、音频和 HDR 占位符需要流探测。尚未探测到的频道/剧集将回退到播放列表源中的手动值 - 某些占位符可能会呈现为空。", "Notification Message": "通知消息", "Notification Subject": "通知主题", @@ -1310,6 +1343,7 @@ "Number of streams available for HDHR and Xtream API service (if using).": "HDHR 和 Xtream API 服务可用的流数量(如果使用)。", "Number of streams available for this playlist (only applies to custom channels assigned to this Custom Playlist).": "此播放列表可用的流数量(仅适用于分配给该自定义播放列表的自定义频道)。", "Number of streams available for this provider. If set to a value other than 0, will prevent any streams from starting if the number of active streams exceeds this value.": "此提供商可用的流数量。如果设置为非 0 值,当活动流数量超过此值时,将阻止开启新的流。", + "Off": "离开", "Offline": "离线", "Online": "在线", "Only VOD channels in these groups will be accessible. Leave empty to allow all VOD groups.": "仅可访问这些组中的 VOD 频道。留空以允许所有 VOD 组。", @@ -1373,6 +1407,7 @@ "Parallel processing": "并行处理", "Password": "密码", "Password for playlist access.": "访问播放列表的密码。", + "Paste cookies.txt content for authenticated streams (e.g. YouTube members-only, age-gated). Get cookies using a browser extension like \"Get cookies.txt LOCALLY\".": "粘贴经过身份验证的流的 cookies.txt 内容(例如仅限 YouTube 会员、年龄限制)。使用浏览器扩展获取 cookie,例如“本地获取 cookies.txt”。", "Path Preview": "路径预览", "Path Validation Failed": "路径验证失败", "Path structure (folders)": "路径结构(文件夹)", @@ -1382,6 +1417,7 @@ "Pending Review": "待审核", "Pending Trust": "待信任", "Performance": "性能", + "Periodic rescoring": "定期重新评分", "Permanently removes the plugin files from the server and deletes its registry record, settings, and run history. This cannot be undone.": "从服务器永久移除插件文件,并删除其注册记录、设置和运行历史。此操作无法撤销。", "Permanently removes this install log entry. The plugin itself (if installed) is not affected.": "永久移除此安装日志条目。插件本身(如果已安装)不受影响。", "Permissions": "权限", @@ -1528,6 +1564,7 @@ "Probe failed": "探测失败", "Probe now": "立即探测", "Probe ran but returned no stream info": "探针已运行但未返回任何流信息", + "Probe ran but returned no usable details.": "探测器已运行但未返回任何可用的详细信息。", "Probe streams after sync": "同步后自动探测", "Probe the episodes of the selected series with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "使用 ffprobe 探测所选系列的剧集以收集流元数据(编解码器、分辨率、比特率、HDR)。此数据支持通过基于流统计的检测来命名 Trash Guide。", "Probe the selected VOD streams with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "使用 ffprobe 探测选定的 VOD 流以收集流元数据(编解码器、分辨率、比特率、HDR)。此数据支持通过基于流统计的检测来命名 Trash Guide。", @@ -1536,6 +1573,7 @@ "Probe this VOD with ffprobe to collect stream metadata (codec, resolution, bitrate, HDR). This data enables Trash Guide naming with stream-stat-based detection.": "使用 ffprobe 探测此 VOD 以收集流元数据(编解码器、分辨率、比特率、HDR)。此数据支持通过基于流统计的检测来命名 Trash Guide。", "Probe timeout (seconds)": "探测超时(秒)", "Probed": "探测完成", + "Probing is disabled for this channel.": "该通道的探测已禁用。", "Probing is disabled for this channel. Enable it in the channel's edit form to allow probe data collection.": "该频道的探测已禁用。在频道的编辑中启用它以允许探测数据。", "Probing started": "探测开始", "Process": "处理", @@ -1605,6 +1643,7 @@ "Quality & Options": "质量和选项", "Quality selector (best, worst, 720p, etc.) followed by optional Streamlink flags. Example: best --hls-live-edge 3": "质量选择器(best、worst、720p 等),后跟可选的 Streamlink 标志。示例:best --hls-live-edge 3", "Queue Manager": "队列管理器", + "Queue a one-off failover rescore for this playlist? Stale channels will be re-probed (subject to the configured staleness window) and failovers re-sorted so the highest-quality source sits first.": "为该播放列表排队一次性故障转移重新评分?过时的通道将被重新探测(根据配置的过时窗口)并重新排序故障转移,以便最高质量的源位于第一位。", "Queue a plugin action from the page header to create the first run.": "从导航栏将插件操作排入队列以执行首次运行。", "Queue a scan from the header to generate the first run. That will populate Live Activity, Run History, and the run detail screen.": "从导航栏将扫描操作排入队列以执行首次运行。这将填充实时活动、运行历史和运行详情界面。", "Queue reset": "队列已重置", @@ -1613,6 +1652,7 @@ "Quick Actions": "快速操作", "Ran At": "执行时间", "Ran at": "执行时间", + "Ranked sources": "排名来源", "Rate Limit (requests/second)": "速率限制(请求/秒)", "Rating": "评分/分级", "Rating (5 based)": "评分(5 分制)", @@ -1620,7 +1660,10 @@ "Re-enable disabled channels that are found to be live. Requires \"Scan all channels\" to be on.": "启用已被禁用的直播频道。需要开启“扫描所有频道”。", "Re-enable live channels": "重新启用直播频道", "Re-probe": "重新探测", + "Re-probe channels older than (days)": "重新探测早于(天)的通道", "Re-run this mapping everytime the EPG is synced?": "是否在每次同步 EPG 时都重新运行此映射?", + "Re-score failovers now": "立即重新评分故障转移", + "Re-score this channel's failovers against current stream stats. Stale channels may be re-probed (subject to the playlist's staleness window). The master channel is never altered — only the failover order changes.": "根据当前流统计数据重新评分该通道的故障转移。过时的频道可能会被重新探测(取决于播放列表的过时窗口)。主通道永远不会改变——只有故障转移顺序发生变化。", "Recall Memories": "召回记忆", "Recent Plugin Installs": "最近安装的插件", "Recent Runs": "最近的运行记录", @@ -1704,6 +1747,9 @@ "Required to send emails, if your provider requires authentication.": "如果您的提供商需要身份验证,则发送邮件时需要此项。", "Required to send emails.": "发送邮件时需要此项。", "Rescan storage": "重新扫描存储", + "Rescore": "重新评分", + "Rescore now": "立即重新评分", + "Rescoring queued": "重新评分已排队", "Reset": "重置", "Reset EPG status so it can be processed again. Only perform this action if you are having problems with the EPG syncing.": "重置 EPG 状态以便重新处理。仅在 EPG 同步出现问题时执行此操作。", "Reset Find & Replace results back to EPG defaults. This will remove any custom values set in the selected column.": "将“查找与替换”结果重置为 EPG 默认值。这将清除所选列中设置的所有自定义值。", @@ -1802,6 +1848,7 @@ "Schedule Start Time": "计划开始时间", "Schedule Type": "编排模式", "Schedule Window": "编排周期窗口", + "Schedule a recurring rescore for every failover group in this playlist. Master channels are never promoted or replaced — only failover sort order changes. Off = rescore manually only via the \"Rescore now\" button.": "为该播放列表中的每个故障转移组安排定期重新评分。主通道永远不会升级或替换——只会更改故障转移排序顺序。关闭 = 仅通过“立即重新评分”按钮手动重新评分。", "Schedule window is already one week": "编排窗口已设为一周", "Scheduled": "已计划", "Scheduled Recordings": "定时录制任务", @@ -2012,6 +2059,14 @@ "Simple authentication for playlist access.": "播放列表访问的简单身份验证。", "Size": "大小", "Skip channels without EPG ID": "跳过没有 EPG ID 的频道", + "Smart": "聪明的", + "Smart channel": "智能通道", + "Smart channel created": "智能频道创建", + "Smart channel without failovers won't stream.": "没有故障转移的智能通道将不会进行流传输。", + "Smart channel — streams the highest-ranked failover automatically": "智能通道 — 自动传输排名最高的故障转移", + "Smart channels cannot be sources": "智能渠道不能作为来源", + "Smart channels must be created from sources within a single playlist. Narrow your selection and try again.": "智能频道必须从单个播放列表中的源创建。缩小您的选择范围并重试。", + "Some channels were skipped": "某些频道被跳过", "Sort": "排序", "Sort Alpha": "按字母顺序排序", "Sort Alpha Configs": "按字母顺序配置", @@ -2193,6 +2248,8 @@ "The database table to query.": "要查询的数据库表。", "The default transcoding profile used by the in-app player for Live content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "应用内播放器播放直播内容时默认使用的转码配置。若已设置频道级别的流配置,则优先使用该配置。留空则禁用转码(部分串流可能无法在播放器中播放)。", "The default transcoding profile used by the in-app player for VOD/Series content. A per-channel stream profile (if set) takes priority over this. Leave empty to disable transcoding (some streams may not be playable in the player).": "应用内播放器播放 VOD/剧集内容时默认使用的转码配置。若已设置频道级别的流配置,则优先使用该配置。留空则禁用转码(部分串流可能无法在播放器中播放)。", + "The default transcoding profile used for the in-app player for Live content. Leave empty to disable transcoding (some streams may not be playable in the player).": "应用内播放器播放直播内容时默认使用的转码配置。留空则禁用转码(部分串流可能无法在播放器中播放)。", + "The default transcoding profile used for the in-app player for VOD/Series content. Leave empty to disable transcoding (some streams may not be playable in the player).": "应用内播放器播放 VOD/剧集内容时默认使用的转码配置。留空则禁用转码(部分串流可能无法在播放器中播放)。", "The event that will trigger this post process.": "触发此后处理任务的事件。", "The event that will trigger this post process. \"VOD/Series Stream Files Synced\" fires after the respective .strm file sync completes (requires .strm sync to be enabled on the playlist).": "将触发此后处理的事件。 “VOD/Series Stream Files Synced”在相应的 .strm 文件同步完成后触发(需要在播放列表上启用 .strm 同步)。", "The file extension of the VOD container (e.g., mp4, mkv, etc.).": "VOD 封装格式的文件扩展名(如 mp4、mkv 等)。", @@ -2499,6 +2556,7 @@ "View the EPG channel mapping jobs and progress here.": "在此查看 EPG 频道映射任务及其进度。", "View/Update Unique Identifier": "查看/更新唯一标识符", "Viewer ID": "观看者 ID", + "Virtual channel title": "虚拟频道标题", "Wait this many seconds after sync completes before triggering the library refresh": "同步完成后等待指定秒数再触发媒体库刷新", "Wait until a specific date/time before starting the broadcast.": "等到特定日期/时间后再开始推流。", "Warning": "警告", @@ -2508,6 +2566,7 @@ "WebDAV Password": "WebDAV 密码", "WebDAV Username": "WebDAV 用户名", "WebSocket Connection Test": "WebSocket 连接测试", + "Weekly": "每周", "Weight": "权重", "Weight (for shuffle)": "权重(用于随机播放)", "What does this plugin do?": "这个插件有什么作用?", @@ -2565,6 +2624,7 @@ "When enabled, the playlist will fetch items by category.": "启用后,播放列表将按分类抓取项目。", "When enabled, the playlist will fetch items by category. This may slow down the import process but can help with larger playlists that time out when fetching all items at once.": "启用后,播放列表将按分类抓取项目。虽然会略微减慢导入速度,但能有效防止因超大型列表一次性加载而导致的请求超时。", "When enabled, the proxy will attempt to start streams even if the provider\\'s reported connection limit has been reached.": "启用后,即使已达到供应商报告的连接数限制,代理仍会尝试启动流。", + "When enabled, the selected source channels will be disabled after being attached as failovers. They'll only be reachable via the new smart channel.": "启用后,选定的源通道在作为故障转移连接后将被禁用。他们只能通过新的智能渠道联系到。", "When enabled, the user will be prompted to set a new password before they can use the application.": "启用后,用户在进入系统前将被强制要求设置新密码。", "When enabled, there will be an additional navigation item (Logs) to view the log file content.": "启用后,导航栏将增加“日志”选项以查看实时系统日志。", "When enabled, this network will continuously broadcast content according to the schedule.": "启用后,该频道将根据节目编排表进行 24 小时连续推流。", @@ -2611,6 +2671,7 @@ "Yes, generate cache now": "是的,立即生成缓存", "Yes, process now": "是的,立即处理", "Yes, refresh now": "是的,立即刷新", + "Yes, rescore now": "是的,现在重新评分", "Yes, reset now": "是的,立即重置", "Yes, sync now": "是的,立即同步", "You are a helpful AI assistant integrated into m3u editor. You help users manage playlists, EPG data, streams, channels, and other features. Be concise and accurate.": "您是集成到 M3U Editor 中的 AI 助手。您可以协助用户管理播放列表、EPG 数据、串流、频道及其他功能。请保持回答简洁、准确。", @@ -2632,6 +2693,7 @@ "Your TMDB API key (v3 auth). You can get one for free at themoviedb.org.": "您的 TMDB API 密钥 (v3 认证)。您可以从 themoviedb.org 免费获取。", "Your m3u proxy API key": "您的 M3U 代理 API 密钥", "Your preferences have been saved successfully.": "您的首选项已成功保存。", + "Your selection includes one or more existing smart channels — pick raw provider channels instead, or remove the smart-channel flag first.": "您的选择包括一个或多个现有智能频道 - 选择原始提供商频道,或者先删除智能频道标志。", "aac": "aac", "and how to use it": "以及如何使用它", "e.g. 2024": "例如 2024", @@ -2679,4 +2741,4 @@ "veryfast": "veryfast", "your-api-key-here": "在此输入您的 API 密钥", "yt-dlp format selector followed by optional flags. Example: bestvideo+bestaudio/best --no-playlist": "yt-dlp 格式选择器后跟可选标志。示例:bestvideo+bestaudio/best --no-playlist" -} \ No newline at end of file +} diff --git a/resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php b/resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php index 01832e053..177dc0ee4 100644 --- a/resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php +++ b/resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php @@ -212,6 +212,12 @@ class="text-orange-600 dark:text-orange-400 font-medium">{{ $stream['failover_ch
+ @if ($stream['model']['is_smart_channel'] ?? false) + + Smart Channel + + @endif @if ($stream['alias_name'] ?? false) Alias: {{ $stream['alias_name'] }} diff --git a/routes/console.php b/routes/console.php index a3f09aef7..acdf8f84f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -72,6 +72,11 @@ ->hourly() ->withoutOverlapping(); +// Re-score failover channels for playlists with auto_rescore_failovers_interval set +Schedule::command('app:rescore-channel-failovers') + ->hourly() + ->withoutOverlapping(); + // Run scheduled plugin invocations Schedule::command('plugins:run-scheduled') ->everyMinute() diff --git a/tests/Feature/ChannelStreamStatsTest.php b/tests/Feature/ChannelStreamStatsTest.php index be4081556..c9b5b6e24 100644 --- a/tests/Feature/ChannelStreamStatsTest.php +++ b/tests/Feature/ChannelStreamStatsTest.php @@ -282,3 +282,196 @@ expect($channel->stream_stats)->toBe([]) ->and($channel->stream_stats_probed_at)->toBeNull(); }); + +// ────────────────────────────────────────────────────────────────────────────── +// Format-level fallback for video bitrate (Issue #330 — Smart Channels) +// ────────────────────────────────────────────────────────────────────────────── + +it('falls back to format.bit_rate minus audio.bit_rate when video bit_rate is null', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => null, // typical for MPEG-TS + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'channels' => 2, + 'bit_rate' => '128000', + ]], + ['format' => [ + 'bit_rate' => '5128000', + 'duration' => '10.0', + 'format_name' => 'mpegts', + ]], + ], + ]); + + expect($channel->getEmbyStreamStats()['ffmpeg_output_bitrate'])->toBe(5000.0); +}); + +it('prefers per-stream video bit_rate over format-level fallback', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => '4500000', + ]], + ['format' => [ + 'bit_rate' => '9999000', + 'duration' => '10.0', + 'format_name' => 'mpegts', + ]], + ], + ]); + + expect($channel->getEmbyStreamStats()['ffmpeg_output_bitrate'])->toBe(4500.0); +}); + +it('uses format.bit_rate directly when audio bit_rate is unavailable', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => null, + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'bit_rate' => null, + ]], + ['format' => [ + 'bit_rate' => '5000000', + 'duration' => '10.0', + 'format_name' => 'mpegts', + ]], + ], + ]); + + expect($channel->getEmbyStreamStats()['ffmpeg_output_bitrate'])->toBe(5000.0); +}); + +it('clamps fallback video bitrate to null when audio bit_rate exceeds format bit_rate', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => null, + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'bit_rate' => '10000000', + ]], + ['format' => [ + 'bit_rate' => '5000000', + 'duration' => '10.0', + 'format_name' => 'mpegts', + ]], + ], + ]); + + expect($channel->getEmbyStreamStats()['ffmpeg_output_bitrate'])->toBeNull(); +}); + +it('parses old stream_stats rows without a format entry without errors', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => null, + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'bit_rate' => '128000', + ]], + ], + ]); + + $result = $channel->getEmbyStreamStats(); + + expect($result['ffmpeg_output_bitrate'])->toBeNull() + ->and($result['resolution'])->toBe('1920x1080') + ->and($result['audio_bitrate'])->toBe(128.0); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// getStreamStatsForDisplay() with format entry +// ────────────────────────────────────────────────────────────────────────────── + +// ────────────────────────────────────────────────────────────────────────────── +// computeBitrateFromPackets() — packet-sampling math used as a fallback when +// neither per-stream nor format-level bit_rate is populated. +// ────────────────────────────────────────────────────────────────────────────── + +it('computes bitrate from packet sizes and pts span', function () { + // 125 packets totalling ~3.75 MB over ~5 s ≈ 6 Mbps + $packets = [ + ['size' => '750000', 'pts_time' => '88441.900000'], + ['size' => '750000', 'pts_time' => '88443.150000'], + ['size' => '750000', 'pts_time' => '88444.400000'], + ['size' => '750000', 'pts_time' => '88445.650000'], + ['size' => '750000', 'pts_time' => '88446.900000'], + ]; + + $bps = Channel::computeBitrateFromPackets($packets); + + expect($bps)->toBe(6_000_000); +}); + +it('returns null when there are fewer than 2 packets', function () { + expect(Channel::computeBitrateFromPackets([]))->toBeNull() + ->and(Channel::computeBitrateFromPackets([['size' => '1000', 'pts_time' => '0.0']]))->toBeNull(); +}); + +it('returns null when packet pts span is zero or negative', function () { + $packets = [ + ['size' => '1000', 'pts_time' => '5.0'], + ['size' => '1000', 'pts_time' => '5.0'], + ]; + + expect(Channel::computeBitrateFromPackets($packets))->toBeNull(); +}); + +it('returns null when total packet bytes is zero', function () { + $packets = [ + ['size' => '0', 'pts_time' => '0.0'], + ['size' => '0', 'pts_time' => '5.0'], + ]; + + expect(Channel::computeBitrateFromPackets($packets))->toBeNull(); +}); + +it('skips format entries when building all_streams display list', function () { + $channel = Channel::factory()->for($this->playlist)->create([ + 'stream_stats' => [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 1920, 'height' => 1080, 'tags' => []]], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'aac', 'channels' => 2, 'tags' => ['language' => 'eng']]], + ['stream' => ['codec_type' => 'audio', 'codec_name' => 'ac3', 'channels' => 6, 'tags' => ['language' => 'spa']]], + ['format' => ['bit_rate' => '5000000', 'duration' => '10.0', 'format_name' => 'mpegts']], + ], + ]); + + $display = $channel->getStreamStatsForDisplay(); + + expect($display['advanced']['all_streams'])->toHaveCount(3) + ->and(collect($display['advanced']['all_streams'])->pluck('codec')->all()) + ->toBe(['h264', 'aac', 'ac3']); +}); diff --git a/tests/Feature/MergeChannelsFpsBitrateScoringTest.php b/tests/Feature/MergeChannelsFpsBitrateScoringTest.php new file mode 100644 index 000000000..8ca0a75e5 --- /dev/null +++ b/tests/Feature/MergeChannelsFpsBitrateScoringTest.php @@ -0,0 +1,248 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + $this->playlist = Playlist::factory()->createQuietly([ + 'user_id' => $this->user->id, + ]); + + $this->group = Group::factory()->createQuietly([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + ]); +}); + +/** + * Helper: build stream_stats array with given fps + video bitrate (kbps). + * Includes a format entry so the value path matches a real probe. + */ +function streamStats(?float $fps = null, ?int $videoKbps = null, int $audioKbps = 128, ?int $width = 1920, ?int $height = 1080): array +{ + $stats = []; + + $videoStream = [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => $width, + 'height' => $height, + ]; + if ($fps !== null) { + $videoStream['avg_frame_rate'] = $fps.'/1'; + } + if ($videoKbps !== null) { + $videoStream['bit_rate'] = (string) ($videoKbps * 1000); + } + $stats[]['stream'] = $videoStream; + + $stats[]['stream'] = [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'channels' => 2, + 'bit_rate' => (string) ($audioKbps * 1000), + ]; + + return $stats; +} + +it('selects high-fps channel as master when fps is the priority', function () { + $low = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fps-test', + 'name' => 'Low FPS', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(fps: 25), + 'stream_stats_probed_at' => now(), + ]); + + $high = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fps-test', + 'name' => 'High FPS', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(fps: 60), + 'stream_stats_probed_at' => now(), + ]); + + (new MergeChannels( + user: $this->user, + playlists: collect([['playlist_failover_id' => $this->playlist->id]]), + playlistId: $this->playlist->id, + weightedConfig: [ + 'priority_attributes' => ['fps'], + 'priority_keywords' => [], + 'prefer_codec' => null, + 'exclude_disabled_groups' => false, + 'group_priorities' => [], + ], + ))->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $high->id, + 'channel_failover_id' => $low->id, + ]); +}); + +it('selects high-bitrate channel as master when bitrate is the priority', function () { + $low = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'br-test', + 'name' => 'Low Bitrate', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(videoKbps: 2000), + 'stream_stats_probed_at' => now(), + ]); + + $high = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'br-test', + 'name' => 'High Bitrate', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(videoKbps: 8000), + 'stream_stats_probed_at' => now(), + ]); + + (new MergeChannels( + user: $this->user, + playlists: collect([['playlist_failover_id' => $this->playlist->id]]), + playlistId: $this->playlist->id, + weightedConfig: [ + 'priority_attributes' => ['bitrate'], + 'priority_keywords' => [], + 'prefer_codec' => null, + 'exclude_disabled_groups' => false, + 'group_priorities' => [], + ], + ))->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $high->id, + 'channel_failover_id' => $low->id, + ]); +}); + +it('uses format-level bitrate fallback for ranking when per-stream video bit_rate is null', function () { + // Channel with format-level bitrate only (typical for MPEG-TS): 8000 kbps total. + $high = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fmt-test', + 'name' => 'Format Fallback Hi', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => [ + ['stream' => [ + 'codec_type' => 'video', 'codec_name' => 'h264', + 'width' => 1920, 'height' => 1080, + 'bit_rate' => null, + ]], + ['stream' => [ + 'codec_type' => 'audio', 'codec_name' => 'aac', + 'channels' => 2, 'bit_rate' => '128000', + ]], + ['format' => ['bit_rate' => '8128000', 'duration' => '10.0', 'format_name' => 'mpegts']], + ], + 'stream_stats_probed_at' => now(), + ]); + + $low = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fmt-test', + 'name' => 'Format Fallback Lo', + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(videoKbps: 2000), + 'stream_stats_probed_at' => now(), + ]); + + (new MergeChannels( + user: $this->user, + playlists: collect([['playlist_failover_id' => $this->playlist->id]]), + playlistId: $this->playlist->id, + weightedConfig: [ + 'priority_attributes' => ['bitrate'], + 'priority_keywords' => [], + 'prefer_codec' => null, + 'exclude_disabled_groups' => false, + 'group_priorities' => [], + ], + ))->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $high->id, + 'channel_failover_id' => $low->id, + ]); +}); + +it('treats missing stream_stats as zero score for fps', function () { + $low = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fps-empty', + 'name' => 'No Stats', + 'sort' => 1.0, + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => null, + 'stream_stats_probed_at' => now(), + 'url' => null, + 'url_custom' => null, + ]); + + $high = Channel::factory()->create([ + 'user_id' => $this->user->id, + 'playlist_id' => $this->playlist->id, + 'group_id' => $this->group->id, + 'stream_id' => 'fps-empty', + 'name' => 'Has Stats', + 'sort' => 2.0, + 'enabled' => true, + 'can_merge' => true, + 'stream_stats' => streamStats(fps: 50), + 'stream_stats_probed_at' => now(), + ]); + + (new MergeChannels( + user: $this->user, + playlists: collect([['playlist_failover_id' => $this->playlist->id]]), + playlistId: $this->playlist->id, + weightedConfig: [ + 'priority_attributes' => ['fps'], + 'priority_keywords' => [], + 'prefer_codec' => null, + 'exclude_disabled_groups' => false, + 'group_priorities' => [], + ], + ))->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $high->id, + 'channel_failover_id' => $low->id, + ]); +}); diff --git a/tests/Feature/RescoreChannelFailoversCommandTest.php b/tests/Feature/RescoreChannelFailoversCommandTest.php new file mode 100644 index 000000000..f399fc867 --- /dev/null +++ b/tests/Feature/RescoreChannelFailoversCommandTest.php @@ -0,0 +1,94 @@ +user = User::factory()->create(); +}); + +it('dispatches rescore for playlists whose interval has elapsed', function () { + $due = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'daily', + 'last_failover_rescore_at' => now()->subDays(2), + ]); + + $notDue = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'daily', + 'last_failover_rescore_at' => now()->subHours(2), + ]); + + $this->artisan('app:rescore-channel-failovers')->assertExitCode(0); + + Bus::assertDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $due->id); + Bus::assertNotDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $notDue->id); +}); + +it('dispatches when last_failover_rescore_at is null (never run)', function () { + $never = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'weekly', + 'last_failover_rescore_at' => null, + ]); + + $this->artisan('app:rescore-channel-failovers')->assertExitCode(0); + + Bus::assertDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $never->id); +}); + +it('skips playlists with no auto_rescore_failovers_interval set', function () { + Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => null, + 'last_failover_rescore_at' => null, + ]); + + $this->artisan('app:rescore-channel-failovers')->assertExitCode(0); + + Bus::assertNotDispatched(RescoreChannelFailovers::class); +}); + +it('skips playlists with an unrecognized interval value', function () { + Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'banana', + 'last_failover_rescore_at' => null, + ]); + + $this->artisan('app:rescore-channel-failovers')->assertExitCode(0); + + Bus::assertNotDispatched(RescoreChannelFailovers::class); +}); + +it('respects the weekly interval', function () { + $dueWeekly = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'weekly', + 'last_failover_rescore_at' => now()->subDays(8), + ]); + + $notDueWeekly = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => 'weekly', + 'last_failover_rescore_at' => now()->subDays(3), + ]); + + $this->artisan('app:rescore-channel-failovers')->assertExitCode(0); + + Bus::assertDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $dueWeekly->id); + Bus::assertNotDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $notDueWeekly->id); +}); + +it('dispatches a single playlist immediately when an ID is passed', function () { + $playlist = Playlist::factory()->for($this->user)->createQuietly([ + 'auto_rescore_failovers_interval' => null, + 'last_failover_rescore_at' => now(), + ]); + + $this->artisan('app:rescore-channel-failovers', ['playlist' => $playlist->id])->assertExitCode(0); + + Bus::assertDispatched(RescoreChannelFailovers::class, fn (RescoreChannelFailovers $job) => $job->playlistId === $playlist->id); +}); + +it('returns failure when the supplied playlist ID does not exist', function () { + $this->artisan('app:rescore-channel-failovers', ['playlist' => 999999])->assertExitCode(1); + Bus::assertNotDispatched(RescoreChannelFailovers::class); +}); diff --git a/tests/Feature/RescoreChannelFailoversTest.php b/tests/Feature/RescoreChannelFailoversTest.php new file mode 100644 index 000000000..7fb07eaaf --- /dev/null +++ b/tests/Feature/RescoreChannelFailoversTest.php @@ -0,0 +1,255 @@ +user = User::factory()->create(); + $this->playlist = Playlist::factory()->for($this->user)->createQuietly([ + 'failover_rescore_staleness_days' => 7, + ]); +}); + +/** + * Build a channel with a known set of stream stats for deterministic scoring. + */ +function rescoreChannel(User $user, Playlist $playlist, array $overrides, ?array $stats = null): Channel +{ + return Channel::factory()->for($user)->for($playlist)->create(array_merge([ + 'enabled' => true, + 'can_merge' => true, + 'probe_enabled' => true, + 'stream_stats' => $stats ?? [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => '5000000', + 'avg_frame_rate' => '25/1', + ]], + ['stream' => [ + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'channels' => 2, + 'bit_rate' => '128000', + ]], + ], + 'stream_stats_probed_at' => now(), + ], $overrides)); +} + +it('reorders failovers so the highest-scoring channel sits at sort=0', function () { + $master = rescoreChannel($this->user, $this->playlist, [ + 'name' => 'Virtual Primary', + 'is_custom' => true, + 'url' => null, + ]); + + $hd = rescoreChannel($this->user, $this->playlist, ['name' => 'HD Source']); + $sd = rescoreChannel($this->user, $this->playlist, ['name' => 'SD Source'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + // Attach in the "wrong" order — SD at sort=0, HD at sort=1. + ChannelFailover::create([ + 'user_id' => $this->user->id, + 'channel_id' => $master->id, + 'channel_failover_id' => $sd->id, + 'sort' => 0, + ]); + ChannelFailover::create([ + 'user_id' => $this->user->id, + 'channel_id' => $master->id, + 'channel_failover_id' => $hd->id, + 'sort' => 1, + ]); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $master->id, + 'channel_failover_id' => $hd->id, + 'sort' => 0, + ]); + $this->assertDatabaseHas('channel_failovers', [ + 'channel_id' => $master->id, + 'channel_failover_id' => $sd->id, + 'sort' => 1, + ]); +}); + +it('flips the effective smart-channel URL when the top failover changes', function () { + $master = rescoreChannel($this->user, $this->playlist, [ + 'name' => 'Virtual Primary', + 'is_custom' => true, + 'url' => null, + ]); + + $hd = rescoreChannel($this->user, $this->playlist, ['name' => 'HD Source', 'url' => 'http://hd.example/stream']); + $sd = rescoreChannel($this->user, $this->playlist, ['name' => 'SD Source', 'url' => 'http://sd.example/stream'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $sd->id, 'sort' => 0, + ]); + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $hd->id, 'sort' => 1, + ]); + + expect((new PlaylistUrlService)->getChannelUrl($master->fresh(), 'http://m3u.test'))->toContain('sd.example'); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + expect((new PlaylistUrlService)->getChannelUrl($master->fresh(), 'http://m3u.test'))->toContain('hd.example'); +}); + +it('never modifies the master channel itself', function () { + $master = rescoreChannel($this->user, $this->playlist, [ + 'name' => 'Master', + 'enabled' => true, + 'is_custom' => false, + ]); + + $failover = rescoreChannel($this->user, $this->playlist, ['name' => 'Failover']); + + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $failover->id, 'sort' => 0, + ]); + + $beforeMasterAttrs = $master->only(['id', 'name', 'enabled', 'is_custom', 'url']); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + $master->refresh(); + expect($master->only(['id', 'name', 'enabled', 'is_custom', 'url']))->toBe($beforeMasterAttrs); +}); + +it('updates last_failover_rescore_at when the job completes', function () { + expect($this->playlist->fresh()->last_failover_rescore_at)->toBeNull(); + + $master = rescoreChannel($this->user, $this->playlist, ['name' => 'Master']); + $failover = rescoreChannel($this->user, $this->playlist, ['name' => 'Failover']); + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $failover->id, 'sort' => 0, + ]); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + expect($this->playlist->fresh()->last_failover_rescore_at)->not->toBeNull(); +}); + +it('updates last_failover_rescore_at even when the playlist has no failover groups', function () { + rescoreChannel($this->user, $this->playlist, ['name' => 'Lonely']); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + expect($this->playlist->fresh()->last_failover_rescore_at)->not->toBeNull(); +}); + +it('does not re-probe channels with fresh stream_stats', function () { + $master = rescoreChannel($this->user, $this->playlist, ['name' => 'Master']); + $failover = rescoreChannel($this->user, $this->playlist, [ + 'name' => 'Failover', + 'stream_stats_probed_at' => now()->subDay(), + ]); + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $failover->id, 'sort' => 0, + ]); + + $probedAt = $failover->fresh()->stream_stats_probed_at; + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + // Within the staleness window — probed_at must remain unchanged. + expect($failover->fresh()->stream_stats_probed_at?->timestamp)->toBe($probedAt?->timestamp); +}); + +it('skips rescoring when the master has no failovers attached', function () { + rescoreChannel($this->user, $this->playlist, ['name' => 'Lonely']); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + expect(ChannelFailover::count())->toBe(0); +}); + +it('persists score breakdown on each failover row after rescoring', function () { + $master = rescoreChannel($this->user, $this->playlist, [ + 'name' => 'Virtual Primary', + 'is_custom' => true, + 'url' => null, + ]); + + $hd = rescoreChannel($this->user, $this->playlist, ['name' => 'HD Source']); + $sd = rescoreChannel($this->user, $this->playlist, ['name' => 'SD Source'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $sd->id, 'sort' => 0, + ]); + ChannelFailover::create([ + 'user_id' => $this->user->id, 'channel_id' => $master->id, 'channel_failover_id' => $hd->id, 'sort' => 1, + ]); + + (new RescoreChannelFailovers($this->playlist->id))->handle(); + + $hdRow = ChannelFailover::where('channel_id', $master->id) + ->where('channel_failover_id', $hd->id) + ->first(); + + expect($hdRow->metadata) + ->toHaveKey('score') + ->and($hdRow->metadata['priority_order'])->toBe(['resolution', 'fps', 'bitrate', 'codec']) + ->and($hdRow->metadata['attribute_scores'])->toHaveKeys(['resolution', 'fps', 'bitrate', 'codec']); +}); + +it('registers a WithoutOverlapping middleware keyed on the playlist id', function () { + $job = new RescoreChannelFailovers(playlistId: 42); + + $middleware = $job->middleware(); + + expect($middleware)->toHaveCount(1) + ->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class) + ->and($middleware[0]->key)->toBe('42'); +}); + +it('uses different overlap keys for different playlists so they do not block each other', function () { + $jobA = new RescoreChannelFailovers(playlistId: 1); + $jobB = new RescoreChannelFailovers(playlistId: 2); + + expect($jobA->middleware()[0]->key)->not->toBe($jobB->middleware()[0]->key); +}); + +it('honors the channelIds filter to scope rescoring to specific masters', function () { + $masterA = rescoreChannel($this->user, $this->playlist, ['name' => 'Master A']); + $masterB = rescoreChannel($this->user, $this->playlist, ['name' => 'Master B']); + + $aHigh = rescoreChannel($this->user, $this->playlist, ['name' => 'A High']); + $aLow = rescoreChannel($this->user, $this->playlist, ['name' => 'A Low'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $bHigh = rescoreChannel($this->user, $this->playlist, ['name' => 'B High']); + $bLow = rescoreChannel($this->user, $this->playlist, ['name' => 'B Low'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + ChannelFailover::create(['user_id' => $this->user->id, 'channel_id' => $masterA->id, 'channel_failover_id' => $aLow->id, 'sort' => 0]); + ChannelFailover::create(['user_id' => $this->user->id, 'channel_id' => $masterA->id, 'channel_failover_id' => $aHigh->id, 'sort' => 1]); + + ChannelFailover::create(['user_id' => $this->user->id, 'channel_id' => $masterB->id, 'channel_failover_id' => $bLow->id, 'sort' => 0]); + ChannelFailover::create(['user_id' => $this->user->id, 'channel_id' => $masterB->id, 'channel_failover_id' => $bHigh->id, 'sort' => 1]); + + (new RescoreChannelFailovers($this->playlist->id, channelIds: [$masterA->id]))->handle(); + + // Master A reordered, Master B left alone. + $this->assertDatabaseHas('channel_failovers', ['channel_id' => $masterA->id, 'channel_failover_id' => $aHigh->id, 'sort' => 0]); + $this->assertDatabaseHas('channel_failovers', ['channel_id' => $masterB->id, 'channel_failover_id' => $bLow->id, 'sort' => 0]); +}); diff --git a/tests/Feature/SmartChannelCreatorTest.php b/tests/Feature/SmartChannelCreatorTest.php new file mode 100644 index 000000000..9a44d3f55 --- /dev/null +++ b/tests/Feature/SmartChannelCreatorTest.php @@ -0,0 +1,242 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + $this->playlist = Playlist::factory()->for($this->user)->createQuietly(); +}); + +function vpChannel(User $user, Playlist $playlist, array $overrides, ?array $stats = null): Channel +{ + return Channel::factory()->for($user)->for($playlist)->create(array_merge([ + 'enabled' => true, + 'can_merge' => true, + 'probe_enabled' => true, + 'is_custom' => false, + 'stream_stats' => $stats ?? [ + ['stream' => [ + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'width' => 1920, + 'height' => 1080, + 'bit_rate' => '5000000', + 'avg_frame_rate' => '25/1', + ]], + ], + 'stream_stats_probed_at' => now(), + ], $overrides)); +} + +it('registers make_smart_channel in the channel BulkModalActionGroup', function () { + $bulkActions = ChannelResource::getTableBulkActions(); + $group = $bulkActions[0]; + + $schemaProp = new ReflectionProperty($group, 'schema'); + $outerSchema = $schemaProp->getValue($group); + + $childProp = new ReflectionProperty(Component::class, 'childComponents'); + $names = []; + + foreach ($outerSchema as $component) { + $children = $childProp->getValue($component)['default'] ?? []; + foreach ($children as $child) { + if ($child instanceof BulkAction) { + $names[] = $child->getName(); + } + } + } + + expect($names)->toContain('make_smart_channel'); +}); + +it('creates a custom channel with copied identity from the highest-scoring source', function () { + $epg = EpgChannel::factory()->create(); + + $high = vpChannel($this->user, $this->playlist, [ + 'title' => 'BBC One HD', + 'name' => 'BBC One HD', + 'logo' => 'http://logos.example/bbc-hd.png', + 'epg_channel_id' => $epg->id, + ]); + + $low = vpChannel($this->user, $this->playlist, [ + 'title' => 'BBC One SD', + 'logo' => 'http://logos.example/bbc-sd.png', + ], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $virtual = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$low, $high])); + + expect($virtual->is_custom)->toBeTrue() + ->and($virtual->url)->toBeNull() + ->and($virtual->title)->toBe('BBC One HD') + ->and($virtual->logo)->toBe('http://logos.example/bbc-hd.png') + ->and($virtual->epg_channel_id)->toBe($epg->id); +}); + +it('attaches all selected channels as failovers in score order', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + $uhd = vpChannel($this->user, $this->playlist, ['title' => '4K'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'hevc', 'width' => 3840, 'height' => 2160]], + ]); + + $virtual = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$sd, $hd, $uhd])); + + $sorted = ChannelFailover::where('channel_id', $virtual->id)->orderBy('sort')->get(); + + expect($sorted)->toHaveCount(3) + ->and($sorted[0]->channel_failover_id)->toBe($uhd->id) + ->and($sorted[1]->channel_failover_id)->toBe($hd->id) + ->and($sorted[2]->channel_failover_id)->toBe($sd->id); +}); + +it('streams the highest-ranked source URL via PlaylistUrlService fallback', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD', 'url' => 'http://hd.example/stream']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD', 'url' => 'http://sd.example/stream'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $virtual = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$sd, $hd])); + + expect((new PlaylistUrlService)->getChannelUrl($virtual->fresh(), 'http://m3u.test'))->toContain('hd.example'); +}); + +it('disables source channels when the disableSources flag is set', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + SmartChannelCreator::fromPlaylist($this->playlist)->create( + channels: collect([$hd, $sd]), + disableSources: true, + ); + + expect($hd->fresh()->enabled)->toBeFalse() + ->and($sd->fresh()->enabled)->toBeFalse(); +}); + +it('leaves source channels enabled when disableSources is false', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$hd, $sd])); + + expect($hd->fresh()->enabled)->toBeTrue() + ->and($sd->fresh()->enabled)->toBeTrue(); +}); + +it('uses the provided title when one is supplied', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'BBC One HD']); + + $virtual = SmartChannelCreator::fromPlaylist($this->playlist)->create( + channels: collect([$hd]), + title: 'BBC One', + ); + + expect($virtual->title)->toBe('BBC One') + ->and($virtual->name)->toBe('BBC One'); +}); + +it('throws when called with an empty channel collection', function () { + SmartChannelCreator::fromPlaylist($this->playlist)->create(collect()); +})->throws(InvalidArgumentException::class); + +it('rejects sources spanning multiple playlists', function () { + $other = Playlist::factory()->for($this->user)->createQuietly(); + + $a = vpChannel($this->user, $this->playlist, ['title' => 'A']); + $b = vpChannel($this->user, $other, ['title' => 'B']); + + SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$a, $b])); +})->throws(InvalidArgumentException::class, 'same playlist'); + +it('rejects existing smart channels as sources', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD']); + + $existingSmart = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$hd, $sd])); + + $thirdSource = vpChannel($this->user, $this->playlist, ['title' => 'Other']); + + SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$existingSmart, $thirdSource])); +})->throws(InvalidArgumentException::class, 'cannot be used as sources'); + +it('persists score and per-attribute breakdown on each channel_failovers row', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $virtual = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$sd, $hd])); + + $hdFailover = ChannelFailover::where('channel_id', $virtual->id) + ->where('channel_failover_id', $hd->id) + ->first(); + + expect($hdFailover->metadata) + ->toHaveKey('score') + ->and($hdFailover->metadata['priority_order'])->toBe(['resolution', 'fps', 'bitrate', 'codec']) + ->and($hdFailover->metadata['attribute_scores'])->toHaveKeys(['resolution', 'fps', 'bitrate', 'codec']) + ->and($hdFailover->metadata['attribute_scores']['resolution'])->toBe(25); // 1920x1080 / 82944 ≈ 25 +}); + +it('flags the created channel with is_smart_channel = true', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $smartChannel = SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$sd, $hd])); + + expect($smartChannel->is_smart_channel)->toBeTrue() + ->and($smartChannel->isSmartChannel())->toBeTrue() + ->and($smartChannel->is_custom)->toBeTrue() + ->and($smartChannel->url)->toBeNull(); +}); + +it('smartChannels query scope filters to flagged channels only', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD']); + + SmartChannelCreator::fromPlaylist($this->playlist)->create(collect([$hd, $sd])); + + $smartChannels = Channel::smartChannels()->get(); + + expect($smartChannels)->toHaveCount(1) + ->and($smartChannels->first()->is_smart_channel)->toBeTrue() + ->and($smartChannels->first()->id)->not->toBe($hd->id) + ->and($smartChannels->first()->id)->not->toBe($sd->id); +}); + +it('rank() returns channels sorted by score with breakdowns', function () { + $hd = vpChannel($this->user, $this->playlist, ['title' => 'HD']); + $sd = vpChannel($this->user, $this->playlist, ['title' => 'SD'], stats: [ + ['stream' => ['codec_type' => 'video', 'codec_name' => 'h264', 'width' => 720, 'height' => 480]], + ]); + + $ranking = SmartChannelCreator::fromPlaylist($this->playlist)->rank(collect([$sd, $hd])); + + expect($ranking)->toHaveCount(2) + ->and($ranking[0]['channel']->id)->toBe($hd->id) + ->and($ranking[1]['channel']->id)->toBe($sd->id) + ->and($ranking[0]['score'])->toBeGreaterThan($ranking[1]['score']) + ->and($ranking[0]['breakdown'])->toHaveKeys(['resolution', 'fps', 'bitrate', 'codec']); +});