Skip to content

feat: Smart Channels (#330)#1070

Open
Warbs816 wants to merge 20 commits into
m3ue:devfrom
Warbs816:feature/issue-330-smart-channels
Open

feat: Smart Channels (#330)#1070
Warbs816 wants to merge 20 commits into
m3ue:devfrom
Warbs816:feature/issue-330-smart-channels

Conversation

@Warbs816
Copy link
Copy Markdown
Contributor

@Warbs816 Warbs816 commented Apr 27, 2026

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 at channel_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

  • Frame Rate and Bitrate added as priority attributes alongside the existing Resolution/Codec/etc. Scoring rules are normalized to 0–100.
  • Probe enrichment: ffprobe now also requests -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

  • "Make smart channel" bulk action on the channels resource. Pre-creation modal shows a ranked preview with per-attribute breakdowns so users can see why the order is what it is before committing.
  • New is_smart_channel flag 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

  • New RescoreChannelFailovers job. Iterates failover groups owned by a playlist, re-probes stale members (within the configured staleness window, throttled via ProviderRequestDelay), and updates channel_failovers.sort. Master is never altered.
  • Console command app:rescore-channel-failovers runs hourly and dispatches per-playlist when the configured interval has elapsed. Single-playlist --playlist <id> invocation also works for ad-hoc runs.
  • WithoutOverlapping queue middleware keyed on playlistId so manual + scheduled fires can't double up against the same playlist.
  • Per-channel "Rescore now" button on the channel info pane.

Visibility

  • Failover Ranking section in the channel info pane: expandable cards per failover (rank + score + breakdown badges), with native <details> disclosures revealing each failover's technical details (resolution, fps, bitrate, codec, audio info, last probed). Score breakdowns persist to channel_failovers.metadata so they're durable, not just a one-shot modal preview.
  • Smart Channel column in the channels list (sparkles icon, info color, only renders for flagged rows).
  • Smart Channel badge on the Stream Monitor when a smart channel's stream is in flight.

Per-playlist settings

  • New "Failover Rescoring" section on the playlist edit page (always visible). Houses the schedule (Off / Daily / Weekly) and the staleness window in days (default 7). The scoring rules themselves come from the existing Auto-Merge "Priority Order" — the new section just controls when and how often things rescore.

How it works under the hood

A smart channel is a custom channel where:

  • is_custom = true
  • is_smart_channel = true (new column)
  • url is null
  • One or more channel_failovers rows 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

  • Channel scrubber doesn't know about failover relationships — a scrubber rule can disable a smart channel's source channels and silently degrade the smart channel's quality. Worth a follow-up issue with a discussion about how scrubbers should treat dependents.
  • HLS variant selection is whatever ffprobe picks — for multi-variant HLS sources, the score reflects the variant ffprobe selected (usually the highest bandwidth listed), not necessarily the variant the user's player would actually adapt to. Could be addressed later with explicit variant-selection flags or per-variant probing.
  • "Make smart channel" bulk action uses existing stats only — it doesn't re-probe stale sources before scoring. Users wanting fresh stats can probe the sources first, or rescore the smart channel right after creation. Documented in the playlist settings helper text.

Test plan

  • Probe a non-smart MPEG-TS live channel and confirm Technical Details now shows a video bitrate (was previously null for many streams)
  • Probe an HLS channel and confirm the same
  • In a playlist's auto-merge config, add Frame Rate or Bitrate to the Priority Order, run a sync, and confirm the master selection respects them
  • On the channels list, multi-select 2+ source channels in the same playlist → "Make smart channel" → check the modal preview shows the ranked breakdown → confirm → verify the new smart channel appears with the sparkles badge
  • Verify the smart channel streams correctly via the M3U / HDHR / Xtream URL — should serve from the top failover
  • Open the smart channel's info pane: Technical Details should be hidden; Failover Ranking section visible with expandable cards showing per-failover stats
  • Click "Rescore now" on the info pane → verify a fresh ranking after the queue worker picks up the job
  • On the playlist edit page, set the Failover Rescoring interval to Daily → verify app:rescore-channel-failovers dispatches a job when run with no args
  • Open the Stream Monitor while the smart channel is being streamed → confirm the Smart Channel badge appears
  • Try "Make smart channel" with channels from two different playlists → expect a warning notification, no creation
  • Try attaching an existing smart channel as a failover via the per-channel Repeater search → expect it to not appear in results

Warbs816 added 16 commits April 26, 2026 00:44
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant