feat: Smart Channels (#330)#1070
Open
Warbs816 wants to merge 20 commits into
Open
Conversation
Extends the auto-merge scoring system with two new priority attributes: - Frame Rate (fps): scores channels by detected video frame rate - Bitrate: scores channels by detected video bitrate (kbps) Both attributes are available in the per-playlist auto-merge config and the bulk "Merge Same ID" action. They follow the same opportunistic-probe pattern as the existing resolution scorer — values come from stream_stats, which is populated by the channel probe. To make video bitrate available for MPEG-TS / HLS streams (where per-stream video bit_rate is typically null), probeStreamStats now also requests ffprobe -show_format and appends a format entry. getEmbyStreamStats falls back to (format.bit_rate - audio.bit_rate) when the per-stream value is missing. Old DB rows without a format entry continue to work; they just don't get the fallback.
ffprobe leaves both per-stream and format-level bit_rate null for live streams without a known duration, so the format-level fallback added in the previous commit had nothing to fall back to. Probe now runs a short follow-up ffprobe with -show_packets -read_intervals "%+5" -select_streams v:0 when the metadata pass yields no video bit_rate, sums packet sizes over the captured pts span, and back-fills the video stream's bit_rate field. The format-level fallback in getEmbyStreamStats remains useful for VOD / MP4 sources that populate format.bit_rate but not per-stream bit_rate, so both paths coexist.
…#330) Slice 2 of Smart Channels: keep failover ordering aligned with current channel quality without ever promoting or replacing the master. Migration adds three columns to playlists: - auto_rescore_failovers_interval (off / daily / weekly) - last_failover_rescore_at - failover_rescore_staleness_days (default 7) New job RescoreChannelFailovers iterates failover groups owned by the playlist, re-probes any member whose stream_stats are older than the staleness window (throttled via ProviderRequestDelay), scores members through ChannelMergeScorer, and updates channel_failovers.sort so the highest-scoring failover sits at sort=0. The master is intentionally left alone — for "virtual primary" masters (custom channel, empty URL) PlaylistUrlService::getChannelUrl already falls back to the first failover, so re-ordering is enough to switch the effective stream URL. Console command app:rescore-channel-failovers iterates playlists with a configured interval and dispatches the job when due. Schedule registers it to run hourly with withoutOverlapping. Direct dispatch via the "Re-score failovers now" header action on the Playlist edit page covers the manual case. ChannelMergeScorer extracts the previously-private MergeChannels scoring logic into a reusable service. MergeChannels now delegates to it so all existing scoring behavior is preserved with no test changes required. VirtualPrimaryCreator service plus a "Make virtual primary" channels bulk action build a custom channel from a selection: identity copied from the highest-scoring source, all selected channels attached as sorted failovers, with an optional toggle to disable the sources.
ChannelMergeScorer gains a scoreBreakdown() method returning per-attribute scores (0-100 each) under the configured priority order. Used by: - The "Make virtual primary" bulk action — now shows a ranked preview table in the confirmation modal so you can see exactly why each source ranked where it did before committing to creation. Each row shows the rank, channel + playlist, total score, and per-attribute breakdown. - VirtualPrimaryCreator — persists the score, breakdown, priority order, and ranked_at timestamp into channel_failovers.metadata. - RescoreChannelFailovers — same, so the rationale is durable for scheduled rescoring runs too. The metadata column was already available on channel_failovers and was previously unused for this purpose, so no schema change is required.
…ands Previous scoring multiplied each attribute (0-100) by positional weights of 4000/3000/2000/1000, producing totals like 207,000. The 1000× scaling factor was cosmetic and made scores hard to interpret at a glance. Algorithm now uses the same positional weights ([N, N-1, ..., 1] for N priorities) but normalizes the total by dividing by the maximum possible weighted sum. Final score is always 0-100, with identical relative ordering to the previous algorithm — pure UX simplification, no ranking behavior change.
When viewing a channel that has at least one failover attached, the info pane (View action) now includes a "Failover Ranking" section that pulls the persisted score and per-attribute breakdown out of channel_failovers.metadata and renders them as a ranked table — same layout as the bulk-action preview, just durable rather than one-shot. The section's header action "Rescore now" dispatches RescoreChannelFailovers scoped to that single channel, so users can refresh the ranking on demand without going through the playlist-level periodic config. Stale failovers are re-probed (subject to the playlist's staleness window); the master is never altered. Section is hidden for channels with no failovers, so it adds nothing to non-virtual-primary records.
Virtual primaries (custom channel + empty URL) can't be probed — they have no URL of their own. The Technical Details section now hides when both flags are set, since the panel would always show "not probed yet". Failover Ranking section is restructured from a flat table into a stack of expandable disclosure cards. Each summary row still shows rank + title + score badge; expanding (native <details>/<summary>, no JS) reveals the score breakdown plus that failover's technical details — resolution, fps, video bitrate, codec, audio info, last probed timestamp. Falls back to a "Not yet probed" hint when stream_stats are empty. Layout reads cleanly at all widths and keeps users from having to navigate to each individual failover channel just to compare quality.
Failover scoring now feeds three separate flows: scheduled rescoring, the per-channel "Rescore now" action, and the "Make virtual primary" bulk action. Previously the interval + staleness inputs lived inside the Auto-Merge Processing section and were hidden until auto-merge was on, which made the staleness window unreachable for users running manual rescoring without auto-merge. Lifts the two fields into a dedicated "Failover Rescoring" section, always visible. Section description spells out which flows it impacts; field helpers note which inputs apply where (in particular: the staleness window applies to scheduled and manual rescoring only, not to the bulk action which scores against existing stats).
User-facing name change with no behavioral impact. "Virtual primary" described what the channel isn't (no URL of its own); "smart channel" describes what it does for users (auto-selects the best source). - Class: VirtualPrimaryCreator → SmartChannelCreator (file renamed via git mv to preserve history) - Test file renamed to match - Bulk action: make_virtual_primary → make_smart_channel; label, modal copy, and success notification updated - PlaylistResource Failover Rescoring section description and helper text reference the new name - RescoreChannelFailovers docblock updated - One test name reflects the new term - Database column / is_custom flag unchanged — those describe the underlying mechanism, not the user-facing concept
Smart-channel-ness was previously emergent from is_custom + empty url + has failovers. The composite check leaks across UI gating, can't easily be queried, and makes it easy for a user to construct a "looks like one but isn't" config that silently fails to stream. Adds an explicit is_smart_channel boolean column with an index. The migration backfills existing channels that match the historic composite shape so rows created via earlier bulk-action runs flip cleanly. Channel model gains an isSmartChannel() helper plus a smartChannels() query scope. SmartChannelCreator now sets the flag on creation. The infolist Technical Details section, the URL field's lock, and the new "Smart" column in the channel list all read the flag directly. The edit form also exposes a "Smart channel" toggle for custom channels so users can manually flip existing wrappers without going through the bulk action. The flag and the underlying mechanism (is_custom + empty url + failovers) stay in sync naturally — the toggle drives the flag, and PlaylistUrlService keeps using the composite check at stream time, so the two never diverge in a user-visible way.
The field is disabled when is_smart_channel is on, so the user can't type into it anyway. The sparkles hint icon (which surfaces the same explanation on hover) stays, and is the right place for the why.
Stream Monitor now surfaces a "Smart Channel" badge (sparkles icon, sky palette to match the existing pill style) on any active stream whose backing channel has is_smart_channel = true. Helps operators spot at a glance when a stream is being routed through the auto-failover layer versus a direct provider URL. Also fixes the channel-list IconColumn that was rendering the sparkles icon for every row regardless of the flag — replaced the .icon() + .boolean() combo with a closure-based icon that returns null for non-smart rows so they render empty.
A smart channel with no failovers attached can't stream — the URL field is locked, and there's nothing to fall back to. Adds a warning banner to the Failover Channels fieldset that appears whenever is_smart_channel is on and the failover repeater is empty. Reactive to both the toggle and the repeater, so users see it the moment they enter the broken state. Doesn't block save (the user might be configuring incrementally) — just makes the gap visible. Also runs app:sync-translations to wrap the new strings (badges, modal copy, helper text, section descriptions, the warning text itself) into lang/en.json. 83 new keys.
…l sources
Four follow-ups from a thorough code review of the smart-channel work:
1. sampleVideoBitrate() previously inherited the full probe timeout, so
one channel could take 2 × probe_timeout (default 30s). Capped to 10s
since -read_intervals "%+5" only reads 5 seconds of stream anyway.
Bounds the rescore job's worst-case runtime under the queue timeout.
2. RescoreChannelFailovers now eager-loads masters with their failover
channels in one query instead of N+1-ing per master.
3. SmartChannelCreator::create() rejects two illegal source mixes:
- Sources spanning multiple playlists (the smart channel ends up
parented to one playlist, leaving cross-playlist failovers
rescore-orphans because the rescore job filters by playlist_id).
- Sources that are themselves smart channels (chained smart channels
have no URL of their own, score 0 across the board, and produce
misleading rankings).
The bulk action surfaces both as user-friendly notifications instead
of unhandled exceptions, plus skips smart-channel rows in the
"Add as failover" bulk action with a count notification.
4. Existing per-channel failover Repeater search now excludes smart
channels from results, matching the bulk-action behavior.
Tests cover the cross-playlist and smart-channel-as-source rejections.
The schedule's withoutOverlapping() locks the dispatcher command, not the dispatched jobs. Two manual rescores, or a manual + scheduled fire once the dispatcher unlocks, can produce two RescoreChannelFailovers running concurrently for the same playlist. Scoring is idempotent so the math is fine, but the second run wastes provider quota by re-probing the same upstream URLs and can race with hand edits to channel_failovers metadata. Adds Laravel's WithoutOverlapping queue middleware keyed on playlistId. If a job is already running for that playlist, additional dispatches are dropped. Lock auto-releases at the job timeout (1 hour) so a crashed worker doesn't hold it indefinitely. Different playlists get independent keys so they can run in parallel. Tests verify the middleware is registered with the correct key and that distinct playlists get distinct keys.
Adds the 92 new keys from this branch's en.json into each locale file, translated via google-translate-php (same library app:generate-translations uses). Skips keys that already happened to exist in some locales. Locale backlogs (~600 unrelated keys missing across the four locales) left untouched — those are out of scope for this feature PR and should go in a dedicated translation-sync change.
…t-channels # Conflicts: # lang/de.json # lang/en.json # lang/fr.json # lang/zh_CN.json
…t-channels # Conflicts: # lang/de.json # lang/en.json # lang/es.json # lang/fr.json # lang/zh_CN.json # resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php
…t-channels # Conflicts: # resources/views/filament/pages/m3u-proxy-stream-monitor.blade.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #330.
Summary
Adds Smart Channels — custom channels that automatically route to the highest-quality source from a configured pool of failovers, with the ranking kept current via periodic and manual rescoring against actual stream stats.
The implementation reuses the existing custom-channel-with-empty-URL fallback in
PlaylistUrlService::getChannelUrl(): a smart channel has no URL of its own, and the playlist streams whatever sits atchannel_failovers.sort = 0. Re-scoring just rewrites the sort column — the master is never promoted, swapped, or modified, so the user-facing identity (title, logo, EPG mapping) is stable forever.What's new
Scoring extensions
-show_format. When ffprobe leaves both per-stream and format-level video bitrate null (typical for live MPEG-TS / HLS), a short follow-up packet-sampling probe (-read_intervals \"%+5\") measures actual throughput and back-fills it. Tested live against MPEG-TS and HLS sources.Smart channel creation
is_smart_channelflag on channels (boolean column with index, backfill migration). Used for query scoping, UI gating, list-page badges, edit-form lockdown of the URL field, and the Stream Monitor badge when an active stream is backed by a smart channel.Failover rescoring
RescoreChannelFailoversjob. Iterates failover groups owned by a playlist, re-probes stale members (within the configured staleness window, throttled viaProviderRequestDelay), and updateschannel_failovers.sort. Master is never altered.app:rescore-channel-failoversruns hourly and dispatches per-playlist when the configured interval has elapsed. Single-playlist--playlist <id>invocation also works for ad-hoc runs.WithoutOverlappingqueue middleware keyed onplaylistIdso manual + scheduled fires can't double up against the same playlist.Visibility
<details>disclosures revealing each failover's technical details (resolution, fps, bitrate, codec, audio info, last probed). Score breakdowns persist tochannel_failovers.metadataso they're durable, not just a one-shot modal preview.Per-playlist settings
How it works under the hood
A smart channel is a custom channel where:
is_custom = trueis_smart_channel = true(new column)urlis nullchannel_failoversrows attach source channels, sorted by score.PlaylistUrlService::getChannelUrl()already had the fallback logic for the first two conditions, so the streaming path needed zero changes. Everything new is around scoring, ranking, and surfacing the rationale.Known limitations / deferred
Test plan
app:rescore-channel-failoversdispatches a job when run with no args