From c931cf049aad6ee38f855ab2cd5112bae7abfac8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:38:32 -0400 Subject: [PATCH 01/49] feat: add Live TV sources, program guides, and DVR --- .github/workflows/ci-rust.yml | 15 +- .github/workflows/ci-windows-installer.yml | 3 +- Cargo.lock | 9 + crates/client-web/src/api.ts | 3 +- crates/client-web/src/app.ts | 8 + crates/client-web/src/app/eventBindings.ts | 2 + crates/client-web/src/app/liveTv.css | 55 ++ crates/client-web/src/app/liveTv.ts | 195 ++++++ crates/client-web/src/app/liveTvActions.ts | 238 +++++++ crates/client-web/src/app/routeData.ts | 3 + crates/client-web/src/app/routes.ts | 1 + crates/client-web/src/app/types.ts | 3 + crates/client-web/src/liveTvApi.ts | 106 +++ crates/client-web/tests/e2e/live-tv.spec.mjs | 117 ++++ crates/client-web/tests/live-tv.test.mjs | 96 +++ crates/server/Cargo.toml | 5 +- .../migrations/b27e4a91c803_live_tv/down.sql | 7 + .../migrations/b27e4a91c803_live_tv/up.sql | 79 +++ crates/server/src/auth.rs | 3 + crates/server/src/db/mod.rs | 1 + crates/server/src/db/schema.rs | 97 +++ crates/server/src/lib.rs | 1 + crates/server/src/live_tv/broadcast.rs | 446 ++++++++++++ crates/server/src/live_tv/dvr.rs | 567 ++++++++++++++++ .../server/src/live_tv/fixtures/broadcast.xml | 30 + .../src/live_tv/fixtures/gracenote.json | 28 + crates/server/src/live_tv/fixtures/guide.xml | 26 + crates/server/src/live_tv/formats.rs | 404 +++++++++++ crates/server/src/live_tv/gracenote.rs | 321 +++++++++ crates/server/src/live_tv/mod.rs | 121 ++++ crates/server/src/live_tv/native.rs | 114 ++++ crates/server/src/live_tv/proxy.rs | 400 +++++++++++ crates/server/src/live_tv/settings.rs | 182 +++++ crates/server/src/live_tv/sources.rs | 539 +++++++++++++++ crates/server/src/live_tv/sources/nextpvr.rs | 173 +++++ crates/server/src/live_tv/storage.rs | 415 ++++++++++++ crates/server/src/live_tv/stream.rs | 434 ++++++++++++ crates/server/src/live_tv/tests.rs | 641 ++++++++++++++++++ crates/server/src/live_tv/tsduck.rs | 452 ++++++++++++ crates/server/src/web/mod.rs | 3 + crates/server/src/web/routes/live_tv.rs | 598 ++++++++++++++++ crates/server/src/web/routes/mod.rs | 25 + .../server/tests/test_web/routes/live_tv.rs | 280 ++++++++ crates/server/tests/test_web/routes/mod.rs | 1 + crates/server/wix/main.wxs | 1 + docs/LIVE_TV.md | 111 +++ docs/README.md | 1 + native/live-tv/CMakeLists.txt | 45 ++ native/live-tv/bridge.cpp | 194 ++++++ .../linux/flatpak/dev.lizardbyte.app.Koko.yml | 6 + packaging/linux/flatpak/modules/tsduck.json | 28 + packaging/live_tv.py | 126 ++++ packaging/macos/package-dmg.sh | 11 + 53 files changed, 7766 insertions(+), 4 deletions(-) create mode 100644 crates/client-web/src/app/liveTv.css create mode 100644 crates/client-web/src/app/liveTv.ts create mode 100644 crates/client-web/src/app/liveTvActions.ts create mode 100644 crates/client-web/src/liveTvApi.ts create mode 100644 crates/client-web/tests/e2e/live-tv.spec.mjs create mode 100644 crates/client-web/tests/live-tv.test.mjs create mode 100644 crates/server/sql/migrations/b27e4a91c803_live_tv/down.sql create mode 100644 crates/server/sql/migrations/b27e4a91c803_live_tv/up.sql create mode 100644 crates/server/src/live_tv/broadcast.rs create mode 100644 crates/server/src/live_tv/dvr.rs create mode 100644 crates/server/src/live_tv/fixtures/broadcast.xml create mode 100644 crates/server/src/live_tv/fixtures/gracenote.json create mode 100644 crates/server/src/live_tv/fixtures/guide.xml create mode 100644 crates/server/src/live_tv/formats.rs create mode 100644 crates/server/src/live_tv/gracenote.rs create mode 100644 crates/server/src/live_tv/mod.rs create mode 100644 crates/server/src/live_tv/native.rs create mode 100644 crates/server/src/live_tv/proxy.rs create mode 100644 crates/server/src/live_tv/settings.rs create mode 100644 crates/server/src/live_tv/sources.rs create mode 100644 crates/server/src/live_tv/sources/nextpvr.rs create mode 100644 crates/server/src/live_tv/storage.rs create mode 100644 crates/server/src/live_tv/stream.rs create mode 100644 crates/server/src/live_tv/tests.rs create mode 100644 crates/server/src/live_tv/tsduck.rs create mode 100644 crates/server/src/web/routes/live_tv.rs create mode 100644 crates/server/tests/test_web/routes/live_tv.rs create mode 100644 docs/LIVE_TV.md create mode 100644 native/live-tv/CMakeLists.txt create mode 100644 native/live-tv/bridge.cpp create mode 100644 packaging/linux/flatpak/modules/tsduck.json create mode 100644 packaging/live_tv.py diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 7b3c8ec8..e657066f 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -108,6 +108,8 @@ jobs: CARGO_TERM_COLOR: always INPUT_RELEASE_VERSION: ${{ inputs.release_version }} TEST_TOOLCHAIN: nightly # libtest's JSON output requires nightly. + # musl must support dlopen for Koko's bundled native TV library. + RUSTFLAGS: ${{ contains(matrix.target, 'musl') && '-C target-feature=-crt-static -C link-arg=-Wl,-rpath,$ORIGIN/live-tv' || '' }} steps: - name: Fix arm64 Alpine container if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') @@ -121,11 +123,14 @@ jobs: apk add --no-cache \ bash \ build-base \ + cmake \ curl \ git \ jq \ + linux-headers \ nodejs \ npm \ + python3 \ openssl-dev \ openssl-libs-static \ p7zip \ @@ -273,6 +278,12 @@ jobs: cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release \ --message-format=json | tee "${RUNNER_TEMP}/cargo-build.json" + - name: Bundle native TV library + if: matrix.run == 'build' + run: >- + python3 packaging/live_tv.py --target "${{ matrix.target }}" + --destination "target/${{ matrix.target }}/release/live-tv" + - name: Bundle shared FFmpeg tools if: matrix.run == 'build' env: @@ -304,7 +315,8 @@ jobs: 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ "./assets" \ "./target/${{ matrix.target }}/release/koko${extension}" \ - "./target/${{ matrix.target }}/release/ffmpeg" + "./target/${{ matrix.target }}/release/ffmpeg" \ + "./target/${{ matrix.target }}/release/live-tv" - name: Prepare build artifact if: matrix.run == 'build' && matrix.build_artifact_path == 'build-artifact' @@ -323,6 +335,7 @@ jobs: "build-artifact/target/${{ matrix.target }}/release/koko${extension}" cp -R "crates/client-web/dist" "build-artifact/crates/client-web/dist" cp -R "target/${{ matrix.target }}/release/ffmpeg" "build-artifact/target/${{ matrix.target }}/release/ffmpeg" + cp -R "target/${{ matrix.target }}/release/live-tv" "build-artifact/target/${{ matrix.target }}/release/live-tv" - name: Upload build artifact if: matrix.run == 'build' diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml index 7eac8284..e9a0d064 100644 --- a/.github/workflows/ci-windows-installer.yml +++ b/.github/workflows/ci-windows-installer.yml @@ -128,7 +128,8 @@ jobs: 7z a "artifacts\koko-${{ matrix.target }}.7z" ` ".\assets" ` ".\target\${{ matrix.target }}\release\koko.exe" ` - ".\target\${{ matrix.target }}\release\ffmpeg" + ".\target\${{ matrix.target }}\release\ffmpeg" ` + ".\target\${{ matrix.target }}\release\live-tv" - name: Resolve MSI package version shell: pwsh diff --git a/Cargo.lock b/Cargo.lock index 53fe82b4..1d67ad07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3873,13 +3873,16 @@ dependencies = [ "diesel_migrations", "dirs", "fern", + "flate2", "image", "imohash", "jsonwebtoken", "keyring", "keyring-core", + "libloading 0.8.9", "libsqlite3-sys", "log", + "md5", "musicbrainz_rs", "objc2-core-foundation", "once_cell", @@ -4197,6 +4200,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.3" diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index 0a1f4bf9..7960b516 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -1337,7 +1337,8 @@ async function readJsonResponse(response: Response): Promise { return undefined as T; } -async function requestJson(method: string, path: string, body?: unknown, timeoutMs?: number): Promise { +/** Send a typed API request with the current account credentials and standard error handling. */ +export async function requestJson(method: string, path: string, body?: unknown, timeoutMs?: number): Promise { if (shouldUseMockApi()) { return getMockJsonFallback(method, path, body); } diff --git a/crates/client-web/src/app.ts b/crates/client-web/src/app.ts index ec4673d6..0d9df7e6 100644 --- a/crates/client-web/src/app.ts +++ b/crates/client-web/src/app.ts @@ -1,4 +1,7 @@ /** Coordinates app startup, route-level data loading, and shell rendering. */ +import { renderLiveTvPage } from './app/liveTv'; +import { syncLiveTvAccount } from './app/liveTvActions'; +import './app/liveTv.css'; import { renderPodcastPage } from './app/podcasts'; import kokoLogoUrl from '../../../assets/Koko.svg'; import { createIcons, icons } from 'lucide'; @@ -327,6 +330,7 @@ function isRailCollapsed(): boolean { } function renderCurrentPage(): string { + syncLiveTvAccount(); switch (state.route.page) { case 'item': return renderItemPage(); @@ -336,6 +340,8 @@ function renderCurrentPage(): string { return renderSettingsPage(); case 'podcasts': return renderPodcastPage(); + case 'live-tv': + return renderLiveTvPage(); default: return renderHomePage(); } @@ -390,6 +396,7 @@ function renderRail(): string { `) .join('')} ${showPodcasts ? `` : ''} +
@@ -412,6 +419,7 @@ function renderRail(): string { ${state.libraries.filter(library => library.path !== 'koko://podcasts').map((library) => ``).join('')} ${showPodcasts ? `` : ''} + diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts index 02752bc1..aa605ae9 100644 --- a/crates/client-web/src/app/eventBindings.ts +++ b/crates/client-web/src/app/eventBindings.ts @@ -1,6 +1,7 @@ /** Binds DOM events for each rendered app tree and owns transient UI timers. */ import { clearBrowseSessions } from './browseSession'; +import { bindLiveTvActions } from './liveTvActions'; import { bindPodcastActions, bindPodcastAddActions, bindPodcastDirectoryPreviews, retryPodcastAction } from './podcastActions'; import { renderPodcastFeature } from './podcasts'; import { bindBookActions } from './bookActions'; @@ -636,6 +637,7 @@ function bindRenderEvents(context: AppEventBindingContext, signal: AbortSignal): }); const { navigateTo, refreshData, refreshPendingMetadataData, render, schedulePendingMetadataRefresh, setElementHtml } = context; bindPodcastActions(signal, context); + bindLiveTvActions(signal, context); bindProfileImagePickers(); bindProviderLogos(); bindApiKeyPickers(); diff --git a/crates/client-web/src/app/liveTv.css b/crates/client-web/src/app/liveTv.css new file mode 100644 index 00000000..1a3176a0 --- /dev/null +++ b/crates/client-web/src/app/liveTv.css @@ -0,0 +1,55 @@ +.live-tv-page { padding: 1.5rem; max-width: 1700px; margin: 0 auto; } +.live-tv-header, .live-tv-guide-controls, .live-tv-tabs, .live-tv-actions, .live-tv-inline-form { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; } +.live-tv-header { justify-content: space-between; margin-bottom: 1.5rem; } +.live-tv-header h1 { margin: .25rem 0; } +.live-tv-header p { color: var(--text-muted); } +.live-tv-tabs { border-bottom: 1px solid var(--surface-raised); padding-bottom: 1rem; margin-bottom: 1.5rem; } +.live-tv-tabs button[aria-pressed="false"] { background: var(--surface-raised); color: inherit; } +.live-tv-guide-controls { margin-bottom: 1rem; } +.live-tv-guide-controls form { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; margin-left: auto; } +.live-tv-page label { display: flex; flex-direction: column; gap: .4rem; } +.live-tv-page label:has(input[type="checkbox"]) { flex-direction: row; align-items: center; } +.live-tv-page input, .live-tv-page select, .live-tv-page textarea { min-width: 0; padding: .6rem; color: inherit; background: var(--surface-base); border: 1px solid var(--surface-raised); border-radius: 6px; } +.live-tv-row { display: grid; grid-template-columns: 170px minmax(0, 1fr); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); margin-bottom: .75rem; overflow: hidden; } +.live-tv-channel { display: flex; flex-direction: column; gap: .5rem; padding: 1rem; background: var(--surface-panel); } +.live-tv-channel button { margin-top: auto; } +.live-tv-channel-logo { width: 80px; height: 40px; object-fit: contain; } +.live-tv-channel-number { color: var(--accent); font-size: .9rem; } +.live-tv-programs { display: flex; overflow-x: auto; align-items: start; gap: .5rem; padding: .5rem; } +.live-tv-program { flex: 1 0 230px; max-width: 420px; background: var(--surface-panel); border-radius: 8px; border: 1px solid transparent; } +.live-tv-program.is-live { border-color: var(--accent); } +.live-tv-program summary { cursor: pointer; padding: .75rem; } +.live-tv-program summary strong, .live-tv-program summary span, .live-tv-program summary small { display: block; margin-bottom: .4rem; } +.live-tv-program summary small { color: var(--text-muted); } +.live-tv-program-details { padding: 0 .75rem .75rem; } +.live-tv-program-details p { white-space: pre-wrap; } +.live-tv-program-image { width: 100%; max-height: 170px; object-fit: cover; border-radius: 6px; } +.live-tv-no-guide { padding: 1rem; color: var(--text-muted); } +.live-tv-record-choices { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } +.live-tv-record-choices label:has(input[type="checkbox"]) { grid-column: 1 / -1; } +.live-tv-error { color: #ffbcaa; padding: .75rem; border-left: 3px solid #ffbcaa; background: #742c211f; } +.live-tv-recording { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; margin-bottom: .75rem; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-recording h3, .live-tv-recording p { margin: .35rem 0; } +.live-tv-panel { padding: 1rem; margin: 1rem 0; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-panel > summary { cursor: pointer; font-weight: 600; } +.live-tv-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; padding-top: 1rem; } +.live-tv-form-help, .live-tv-guide-account { grid-column: 1 / -1; } +.live-tv-form-help { color: var(--text-muted); margin: 0; } +.live-tv-guide-account label { margin: .75rem 0; } +.live-tv-source-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } +.live-tv-source { padding: 1rem; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-source .live-tv-actions { margin-top: 1rem; } +.live-tv-upload { font-size: .9rem; } +.live-tv-channel-mapping { display: grid; grid-template-columns: minmax(120px, 1fr) auto minmax(150px, 2fr) auto; gap: 1rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid var(--surface-raised); } +.live-tv-player { position: fixed; inset: 0; z-index: 10000; background: #050912f5; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; margin: 0; width: 100%; height: 100%; max-width: none; max-height: none; border: 0; color: inherit; } +.live-tv-player > button { align-self: end; } +.live-tv-player video { width: 100%; height: min(75vh, 900px); } +.live-tv-player p { color: #ffbcaa; } +@media (max-width: 700px) { + .live-tv-page { padding: .75rem; } + .live-tv-row { grid-template-columns: 120px minmax(0, 1fr); } + .live-tv-channel { padding: .65rem; } + .live-tv-form, .live-tv-channel-mapping { grid-template-columns: 1fr; } + .live-tv-recording { flex-direction: column; align-items: stretch; } + .live-tv-guide-controls form { margin-left: 0; } +} diff --git a/crates/client-web/src/app/liveTv.ts b/crates/client-web/src/app/liveTv.ts new file mode 100644 index 00000000..ca1f030f --- /dev/null +++ b/crates/client-web/src/app/liveTv.ts @@ -0,0 +1,195 @@ +/** Renders the Live TV guide, program details, personal DVR, and source setup. */ +import type { TvChannel, TvProgram, TvRecording, TvSource } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; +import { state } from './state'; +import { renderIcon } from './ui'; +import type { LiveTvState } from './liveTvActions'; + +/** Match source-scoped station IDs, respecting a user's explicit channel mapping. */ +export function channelPrograms(channel: TvChannel, programs: TvProgram[]): TvProgram[] { + const key = channel.guide_override ?? channel.guide_key; + return programs.filter(p => p.source_id === channel.source_id && p.guide_key === key).sort((a, b) => a.starts_at - b.starts_at); +} + +/** Format UTC epoch seconds in the viewer's local timezone. */ +export function tvTime(seconds: number, date = false): string { + return new Date(seconds * 1000).toLocaleString(undefined, date + ? { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' } + : { hour: 'numeric', minute: '2-digit' }); +} + +/** Reject active or local-file artwork URLs before inserting them into the guide. */ +export function tvImage(value?: string | null): string { + if (!value) return ''; + try { const url = new URL(value); return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password ? url.href : ''; } + catch { return ''; } +} + +function picture(value?: string | null, className = ''): string { + const url = tvImage(value); + return url ? `` : ''; +} + +/** Render data only for the account that fetched it. */ +export function renderLiveTvPage(): string { + const data = state.liveTv; + if (!data || data.owner !== state.bootstrap?.current_user?.id) return '

Live TV

Loading channels and guide…

'; + const admin = state.bootstrap?.current_user?.admin === true; + const tab = data.tab ?? 'guide'; + const tabs = [{ key: 'guide', label: 'Guide' }, { key: 'recordings', label: 'Recordings' }]; + if (admin) tabs.push({ key: 'setup', label: 'Setup' }); + let content = renderGuide(data); + if (tab === 'recordings') content = renderRecordings(data); + if (tab === 'setup' && admin) content = renderSetup(data); + return `
+

Television

Live TV

Live channels, your guide, and recordings.

+
+ + ${data.error ? `` : ''} + ${data.message ? `

${escape(data.message)}

` : ''} + ${data.loading ? '

Updating Live TV…

' : ''}${content}
`; +} + +function renderProgram(channel: TvChannel, program: TvProgram): string { + const metadata = program.metadata; + const now = Date.now() / 1000; + const live = program.starts_at <= now && program.ends_at > now; + const tags = [...(metadata.categories ?? []), metadata.episode, metadata.rating].filter(Boolean).join(' · '); + return `
+ ${escape(tvTime(program.starts_at))}–${escape(tvTime(program.ends_at))}${live ? ' · Live' : ''} + ${escape(program.title)}${metadata.subtitle ? `${escape(metadata.subtitle)}` : ''} +
${picture(metadata.image, 'live-tv-program-image')}

${escape(metadata.description ?? 'No program description available.')}

+ ${tags ? `

${escape(tags)}

` : ''}${metadata.credits?.length ? `

${escape(metadata.credits.join(', '))}

` : ''} + ${program.ends_at > now ? renderRecordingChoices(channel, program) : ''}
`; +} + +function renderRecordingChoices(channel: TvChannel, program: TvProgram): string { + if (state.liveTv?.canRecord === false) return '

Recording is disabled for your account.

'; + return `
+ + + + + +
`; +} + +function renderGuide(data: LiveTvState): string { + const groups = [...new Set(data.channels.map(c => c.group_name).filter(Boolean))].sort((a, b) => a.localeCompare(b)); + const query = (data.query ?? '').toLocaleLowerCase(); + const channels = data.channels.filter(c => c.enabled && c.available && (!data.group || c.group_name === data.group) + && (!query || `${c.number} ${c.name}`.toLocaleLowerCase().includes(query))); + const rows = channels.map(channel => { + const programs = channelPrograms(channel, data.programs); + return `
${picture(channel.logo, 'live-tv-channel-logo')} + ${escape(channel.number)}${escape(channel.name)} +
+
${programs.length ? programs.map(p => renderProgram(channel, p)).join('') : '

No guide data. You can still watch this channel or schedule a recording.

'}
`; + }).join(''); + return `
+ + ${escape(tvTime(data.windowStart, true))} +
+
+ ${rows || '

No channels available. Add a TV source in Setup, or ask the server administrator to configure one.

'}`; +} + +function renderRecording(recording: TvRecording, data: LiveTvState): string { + const active = ['scheduled', 'recording'].includes(recording.state); + const conflict = data.conflicts.includes(recording.id); + const cancelLabel = recording.state === 'recording' ? 'Stop recording' : 'Cancel recording'; + const play = recording.has_video ? `` : ''; + let controls = active + ? `` + : `${play}`; + if (recording.state === 'stopping') controls = 'Stopping recording…'; + if (recording.state === 'deleting') controls = 'Deleting recording…'; + if (recording.user_id !== data.owner) controls = play; + const error = recording.last_error ? `

${escape(recording.last_error)}

` : ''; + const description = recording.metadata?.description ? `

${escape(recording.metadata.description)}

` : ''; + return `

${escape(recording.title)}

${escape(tvTime(recording.starts_at, true))}–${escape(tvTime(recording.ends_at))}

+

${escape(recording.state)} · ${recording.shared ? 'Shared' : 'Private'}${conflict ? ' · Tuner conflict' : ''}

${description}${error}
${controls}
`; +} + +function renderRecordings(data: LiveTvState): string { + const rules = data.rules.map(rule => { + const stop = rule.user_id === data.owner ? `` : ''; + return `
${escape(rule.title)}

${rule.new_only ? 'New episodes only' : 'All episodes'} · ${rule.shared ? 'Shared' : 'Private'} · Channel ${escape(data.channels.find(c => c.id === rule.channel_id)?.name ?? String(rule.channel_id))}

${stop}
`; + }).join(''); + return `

Recordings

${data.conflicts.length ? '

Some recordings overlap beyond the available tuner capacity. Cancel a conflicting recording or increase the source limit if more tuners are available.

' : ''} + ${data.recordings.map(r => renderRecording(r, data)).join('') || '

No recordings scheduled.

'} +

Series

${rules || '

Open a program in the guide and choose Record series.

'} + ${renderManualRecording(data)}`; +} + +function renderManualRecording(data: LiveTvState): string { + if (data.canRecord === false) return '

Recording is disabled for your account.

'; + return `
Schedule by time
+ + +
`; +} + +function renderSource(source: TvSource): string { + const scan = source.kind === 'native' ? `` : ''; + return `

${escape(source.name)}

${escape(source.kind)} · ${source.enabled ? 'Enabled' : 'Disabled'}

+ ${source.last_error ? `

${escape(source.last_error)}

` : '

Channels and guide refresh automatically.

'} +
+
+
${scan} +
+
Replace connection settings

Saved credentials are hidden. Enter the complete connection and guide settings again to replace them.

${renderAddSource(source)}
`; +} + +function renderChannelMapping(channel: TvChannel, data: LiveTvState): string { + const stations = data.stations.filter(s => s.source_id === channel.source_id); + const options = stations.map(s => { + const label = `${s.number} ${s.name} (${s.guide_key})`; + return ``; + }).join(''); + return `
${escape(channel.number)} ${escape(channel.name)} + + + ${channel.available ? '' : 'Missing from source'}
`; +} + +function renderSetup(data: LiveTvState): string { + return `

TV sources

Add HDHomeRun, an IPTV M3U playlist, or a local Hauppauge or other tuner directly. NextPVR and Tvheadend are also available as optional sources.

+
${data.sources.map(renderSource).join('')}
+
Add a TV source${renderAddSource()}
+
Channel visibility and guide mapping

Choose a guide station when the automatic match is missing or incorrect.

${data.channels.map(c => renderChannelMapping(c, data)).join('')}
+ ${renderDvrSettings(data)}`; +} + +function renderDvrSettings(data: LiveTvState): string { + const settings = data.dvrSettings; if (!settings) return ''; + const permissions = settings.users.map(user => `
${escape(user.username)}
`).join(''); + return `
DVR storage and recording permissions +

Use an absolute folder path on the server. This applies to new captures; existing recordings keep their locations.

+

Recordings are shared by default. Anyone can watch shared recordings; the account that scheduled a recording manages it.

+ ${permissions}
`; +} + +function renderAddSource(source?: TvSource): string { + const identity = source ? `data-tv-replace-source="${source.id}"` : 'id="tv-source-form"'; + const options = [['hdhomerun', 'HDHomeRun'], ['m3u', 'IPTV (M3U)'], ['native', 'Local tuner (Windows BDA / Linux DVB)'], ['nextpvr', 'NextPVR'], ['tvheadend', 'Tvheadend'], ['dvb', 'Linux DVB channel file (v4l-utils)']] + .filter(([id]) => !source || source.kind === id).map(([id, label]) => ``).join(''); + return `
+ + + + + + + +

Zero uses the provider default: daily for Gracenote. Channels and guide update when the source is added.

+

Automatic uses the guide from HDHomeRun, NextPVR, or Tvheadend. For IPTV, enter an XMLTV URL or import an XMLTV file after adding the source. HDHomeRun includes a short guide without a separate guide account.

+ + +
`; +} diff --git a/crates/client-web/src/app/liveTvActions.ts b/crates/client-web/src/app/liveTvActions.ts new file mode 100644 index 00000000..d7afd3b9 --- /dev/null +++ b/crates/client-web/src/app/liveTvActions.ts @@ -0,0 +1,238 @@ +/** Loads account-scoped Live TV data and binds source, guide, playback, and DVR actions. */ +import * as api from '../liveTvApi'; +import type { AppState } from './types'; +import { state } from './state'; +import { closeActivePlaybackSession } from './playbackController'; +import { stopMusic } from './audioPlayer'; + +/** Live TV data belongs to the account that fetched it; setup data is administrator-only. */ +export interface LiveTvState { + owner: number; channels: api.TvChannel[]; programs: api.TvProgram[]; stations: api.TvStation[]; + recordings: api.TvRecording[]; rules: api.TvSeriesRule[]; sources: api.TvSource[]; conflicts: number[]; + windowStart: number; tab: 'guide' | 'recordings' | 'setup'; query?: string; group?: string; + loading: boolean; error?: string; message?: string; + canRecord?: boolean; dvrSettings?: api.TvDvrSettings; +} + +/** Fetch guide and visible DVR data together, rejecting stale account or route responses. */ +let loadGeneration = 0; +type LiveTvApi = Pick & Partial>; + +/** Ignore older guide windows as well as responses belonging to previous accounts. */ +export async function loadLiveTv(app: AppState, isCurrent: () => boolean, source: LiveTvApi = api): Promise { + const generation = ++loadGeneration; + const owner = app.bootstrap?.current_user?.id; + if (owner === undefined) { app.liveTv = undefined; return; } + const previous = app.liveTv?.owner === owner ? app.liveTv : undefined; + const data: LiveTvState = previous ?? { owner, channels: [], programs: [], stations: [], recordings: [], rules: [], sources: [], conflicts: [], windowStart: Math.floor(Date.now() / 1800000) * 1800, tab: 'guide', loading: true }; + app.liveTv = data; + data.loading = true; + const admin = app.bootstrap?.current_user?.admin === true; + try { + const [channels, guide, recordings, rules, sources, dvrSettings] = await Promise.all([ + source.getTvChannels(admin && data.tab === 'setup'), source.getTvGuide(data.windowStart, data.windowStart + 6 * 3600), + source.getTvRecordings(), source.getTvRules(), admin ? source.getTvSources() : Promise.resolve([]), + admin && data.tab === 'setup' ? source.getTvDvrSettings?.() : undefined, + ]); + if (generation !== loadGeneration || !isCurrent() || app.bootstrap?.current_user?.id !== owner) return; + app.liveTv = { ...data, channels, programs: guide.programs, stations: guide.stations, recordings: recordings.recordings, conflicts: recordings.conflicts, canRecord: recordings.can_record, dvrSettings, rules, sources, error: undefined, loading: false }; + } catch (error) { + if (generation === loadGeneration && isCurrent() && app.bootstrap?.current_user?.id === owner) { + data.error = error instanceof Error ? error.message : 'Live TV could not be loaded.'; + data.loading = false; + } + } +} + +function value(form: FormData, name: string): string { return String(form.get(name) ?? '').trim(); } +function sourceConfig(form: FormData): api.TvSourceConfig { + const selection = value(form, 'gracenote_provider'); + const gracenote = selection ? JSON.parse(selection) as api.TvGuideSelection : undefined; + if (value(form, 'guide_kind') === 'gracenote' && (!gracenote || gracenote.country !== value(form, 'guide_country') || gracenote.postal_code !== value(form, 'guide_postal').toUpperCase().replace(/ /g, ''))) throw new Error('Search for and select a guide provider for this postal code.'); + return { url: value(form, 'url'), pin: value(form, 'pin'), xmltv_url: value(form, 'xmltv_url'), guide_kind: value(form, 'guide_kind'), + gracenote: gracenote ? { ...gracenote, days: Number(value(form, 'guide_days')) } : undefined, refresh_hours: Number(value(form, 'refresh_hours')), + native_device: value(form, 'native_device'), delivery_system: value(form, 'delivery_system'), region: value(form, 'region'), tuning_options: value(form, 'tuning_options'), + adapter: Number(value(form, 'adapter')), frontend: Number(value(form, 'frontend')), zap_path: value(form, 'zap_path'), + multiplex_urls: value(form, 'multiplex_urls').split(/\r?\n/).map(s => s.trim()).filter(Boolean) }; +} +function padding(form: FormData): { padding_before: number; padding_after: number } { + return { padding_before: Number(value(form, 'before')) * 60, padding_after: Number(value(form, 'after')) * 60 }; +} + +interface Context { navigateTo: (path: string) => void; render: () => void } +let player: { owner: number; root: HTMLElement } | undefined; + +/** Close native playback on sign-out or account changes. */ +export function syncLiveTvAccount(): void { + if (player && (player.owner !== state.bootstrap?.current_user?.id || state.route.page !== 'live-tv')) closePlayer(); +} + +function closePlayer(): void { + const video = player?.root.querySelector('video'); + video?.pause(); + video?.removeAttribute('src'); + video?.load(); + player?.root.remove(); + player = undefined; +} + +function play(kind: 'channels' | 'recordings', id: number): void { + const owner = state.bootstrap?.current_user?.id; + if (owner === undefined) return; + closePlayer(); closeActivePlaybackSession(); stopMusic(); + const root = document.createElement('dialog'); + root.className = 'live-tv-player'; + root.setAttribute('role', 'dialog'); root.setAttribute('aria-modal', 'true'); root.setAttribute('aria-label', 'Live TV player'); + root.innerHTML = ''; + document.body.append(root); + root.showModal(); + player = { owner, root }; + const video = root.querySelector('video'); + root.querySelector('button')?.addEventListener('click', closePlayer); + root.addEventListener('keydown', event => { if (event.key === 'Escape') closePlayer(); }); + root.addEventListener('cancel', event => { event.preventDefault(); closePlayer(); }); + if (video) { + video.src = api.tvStreamUrl(kind, id); + video.addEventListener('error', () => { + const message = root.querySelector('p'); + if (message) { message.hidden = false; message.textContent = 'Playback could not start. Check source availability, free tuners, and the server FFmpeg settings.'; } + }); + } + root.querySelector('button')?.focus(); +} + +/** Bind route controls once per render and cancel listeners when the app replaces its DOM. */ +export function bindLiveTvActions(signal: AbortSignal, context: Context): void { + document.querySelectorAll('[data-nav-live-tv]').forEach(button => button.addEventListener('click', () => context.navigateTo('/live-tv'), { signal })); + syncLiveTvAccount(); + if (state.route.page !== 'live-tv' || state.liveTv?.owner !== state.bootstrap?.current_user?.id) return; + const owner = state.liveTv?.owner; + const current = () => state.route.page === 'live-tv' && state.bootstrap?.current_user?.id === owner; + const reload = async () => { await loadLiveTv(state, current); if (current()) context.render(); }; + const run = async (operation: () => Promise, button?: HTMLButtonElement) => { + if (button) button.disabled = true; + try { + await operation(); + if (current() && state.liveTv) state.liveTv.error = undefined; + if (current()) await reload(); + } catch (error) { + if (current() && state.liveTv) { state.liveTv.error = error instanceof Error ? error.message : 'The TV operation failed.'; context.render(); } + } finally { if (button) button.disabled = false; } + }; + document.querySelector('[data-tv-refresh]')?.addEventListener('click', () => { void reload(); }, { signal }); + document.querySelectorAll('[data-tv-tab]').forEach(button => button.addEventListener('click', () => { + if (!state.liveTv) return; + state.liveTv.tab = button.dataset.tvTab as LiveTvState['tab']; context.render(); void reload(); + }, { signal })); + document.querySelectorAll('[data-tv-window]').forEach(button => button.addEventListener('click', () => { + if (!state.liveTv) return; + const step = Number(button.dataset.tvWindow); + state.liveTv.windowStart = step ? state.liveTv.windowStart + step * 6 * 3600 : Math.floor(Date.now() / 1800000) * 1800; + void reload(); + }, { signal })); + document.querySelector('#tv-filter-form')?.addEventListener('submit', event => { + event.preventDefault(); if (!state.liveTv) return; + const form = new FormData(event.currentTarget as HTMLFormElement); + state.liveTv.query = value(form, 'query'); state.liveTv.group = value(form, 'group'); context.render(); + }, { signal }); + bindPlayback(signal); + bindRecordingForms(signal, run); + bindRecordingButtons(signal, run); + if (state.bootstrap?.current_user?.admin) bindSetup(signal, run); + const timer = globalThis.setTimeout(() => { + if (current() && state.liveTv?.tab !== 'setup' && !(document.activeElement instanceof HTMLInputElement)) void reload(); + }, 60000); + signal.addEventListener('abort', () => globalThis.clearTimeout(timer), { once: true }); +} + +type Run = (operation: () => Promise, button?: HTMLButtonElement) => Promise; +function bindPlayback(signal: AbortSignal): void { + document.querySelectorAll('[data-tv-play]').forEach(button => button.addEventListener('click', () => play('channels', Number(button.dataset.tvPlay)), { signal })); + document.querySelectorAll('[data-tv-recording-play]').forEach(button => button.addEventListener('click', () => play('recordings', Number(button.dataset.tvRecordingPlay)), { signal })); +} +function bindRecordingForms(signal: AbortSignal, run: Run): void { + document.querySelectorAll('[data-tv-program-record]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); + const program = state.liveTv?.programs.find(p => p.id === Number(form.dataset.tvProgramRecord)); + if (!program) return; + const input = new FormData(form); + const button = (event as SubmitEvent).submitter as HTMLButtonElement | undefined; + void run(() => api.scheduleTvRecording({ channel_id: Number(form.dataset.tvChannel), title: program.title, starts_at: program.starts_at, ends_at: program.ends_at, series: button?.value === 'series', new_only: input.has('new-only'), private: input.has('private'), ...padding(input) }), button); + }, { signal })); + document.querySelector('#tv-manual-record-form')?.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(event.currentTarget as HTMLFormElement); + void run(() => api.scheduleTvRecording({ channel_id: Number(value(input, 'channel')), title: value(input, 'title'), starts_at: Math.floor(new Date(value(input, 'start')).getTime() / 1000), ends_at: Math.floor(new Date(value(input, 'end')).getTime() / 1000), series: false, new_only: false, private: input.has('private'), ...padding(input) })); + }, { signal }); +} +function bindRecordingButtons(signal: AbortSignal, run: Run): void { + const actions = [ + { selector: '[data-tv-cancel]', key: 'tvCancel', action: api.cancelTvRecording }, + { selector: '[data-tv-delete]', key: 'tvDelete', action: api.deleteTvRecording }, + { selector: '[data-tv-delete-rule]', key: 'tvDeleteRule', action: api.deleteTvRule }, + { selector: '[data-tv-source-refresh]', key: 'tvSourceRefresh', action: api.refreshTvSource }, + { selector: '[data-tv-source-scan]', key: 'tvSourceScan', action: api.rescanTvTuner }, + ]; + for (const item of actions) document.querySelectorAll(item.selector).forEach(button => button.addEventListener('click', () => { void run(() => item.action(Number(button.dataset[item.key])), button); }, { signal })); +} +function bindSetup(signal: AbortSignal, run: Run): void { + document.querySelectorAll('[data-tv-discover-tuners]').forEach(button => button.addEventListener('click', () => { + const form = button.closest('form'); const select = form?.querySelector('[name="native_device"]'); const status = form?.querySelector('[data-tv-tuner-status]'); + if (!select || !status) return; + button.disabled = true; status.textContent = 'Finding tuners…'; + void api.discoverTvTuners().then(devices => { + if (signal.aborted) return; + select.replaceChildren(); + for (const device of devices) { const option = document.createElement('option'); option.value = device.id; option.textContent = device.info ? `${device.name} · ${device.info}` : device.name; select.append(option); } + status.textContent = devices.length ? 'Select a tuner. Adding the source scans its channels.' : 'No tuners found. Check the driver and server device permissions.'; + }).catch((error: unknown) => { if (!signal.aborted) status.textContent = error instanceof Error ? error.message : 'Tuner discovery failed.'; }).finally(() => { button.disabled = false; }); + }, { signal })); + document.querySelector('#tv-dvr-settings')?.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(event.currentTarget as HTMLFormElement); + void run(() => api.setTvDvrSettings(value(input, 'recording_dir'))); + }, { signal }); + document.querySelectorAll('[data-tv-permission]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvRecordingPermission(Number(form.dataset.tvPermission), input.has('can_record'))); + }, { signal })); + document.querySelectorAll('[data-tv-provider-search]').forEach(button => button.addEventListener('click', () => { + const form = button.closest('form'); if (!form) return; + const input = new FormData(form); const country = value(input, 'guide_country'); const postal = value(input, 'guide_postal').toUpperCase().replace(/ /g, ''); + const select = form.querySelector('[name="gracenote_provider"]'); const status = form.querySelector('[data-tv-provider-status]'); + if (!select || !status) return; + button.disabled = true; status.textContent = 'Finding providers…'; select.replaceChildren(); + void api.findTvGuideProviders(country, postal).then(providers => { + if (signal.aborted) return; + for (const provider of providers) { + const option = document.createElement('option'); + const location = provider.location ? ' · ' + provider.location : ''; + option.textContent = `${provider.type} · ${provider.name}${location}`; + option.value = JSON.stringify({ country, postal_code: postal, lineup_id: provider.lineupId, headend_id: provider.headendId, device: provider.device, timezone: provider.timezone }); select.append(option); + } + status.textContent = providers.length ? 'Select your provider, then save the source.' : 'No providers found.'; + }).catch((error: unknown) => { if (!signal.aborted) status.textContent = error instanceof Error ? error.message : 'Provider lookup failed.'; }).finally(() => { button.disabled = false; }); + }, { signal })); + document.querySelectorAll('[data-tv-replace-source]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.replaceTvSource(Number(form.dataset.tvReplaceSource), { name: value(input, 'name'), kind: value(input, 'kind'), capacity: Number(value(input, 'capacity')), config: sourceConfig(input) }), (event as SubmitEvent).submitter as HTMLButtonElement | undefined); + }, { signal })); + document.querySelector('#tv-source-form')?.addEventListener('submit', event => { + event.preventDefault(); const form = event.currentTarget as HTMLFormElement; + const input = new FormData(form); + void run(async () => { + const result = await api.addTvSource({ name: value(input, 'name'), kind: value(input, 'kind'), capacity: Number(value(input, 'capacity')), config: sourceConfig(input) }); + form.reset(); + if (state.liveTv) state.liveTv.message = result.refresh_error ?? 'TV source added. Channels and guide will refresh in the background.'; + }, (event as SubmitEvent).submitter as HTMLButtonElement | undefined); + }, { signal }); + document.querySelectorAll('[data-tv-source-update]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvSource({ id: Number(form.dataset.tvSourceUpdate), enabled: input.has('enabled'), capacity: Number(value(input, 'capacity')) })); + }, { signal })); + document.querySelectorAll('[data-tv-channel-update]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvChannel(Number(form.dataset.tvChannelUpdate), input.has('enabled'), value(input, 'guide') || null)); + }, { signal })); + document.querySelectorAll('[data-tv-guide-upload]').forEach(input => input.addEventListener('change', () => { + const file = input.files?.[0]; if (file) void run(() => api.uploadTvGuide(Number(input.dataset.tvGuideUpload), file)); + }, { signal })); +} diff --git a/crates/client-web/src/app/routeData.ts b/crates/client-web/src/app/routeData.ts index 7a5cccd0..2229093e 100644 --- a/crates/client-web/src/app/routeData.ts +++ b/crates/client-web/src/app/routeData.ts @@ -1,6 +1,7 @@ /** Loads route-specific data while rejecting stale responses after navigation. */ import * as api from '../api'; +import { loadLiveTv } from './liveTvActions'; import { loadPodcasts } from './podcastActions'; import type { AppRoute, AppState } from './types'; import { currentLogFilterRequest } from './activities'; @@ -128,6 +129,8 @@ export async function loadRouteData( } else if (route.page === 'podcasts') { tasks.push(loadPodcasts(state, isCurrent, render)); loadGlobalSearch(state, source, load); + } else if (route.page === 'live-tv') { + tasks.push(loadLiveTv(state, isCurrent).then(render)); } else if (route.page === 'person') { load('person details', source.getPerson(route.personId), (value) => { state.selectedPerson = value; }); } else if (route.section === 'dashboard') { diff --git a/crates/client-web/src/app/routes.ts b/crates/client-web/src/app/routes.ts index 59409865..512423f1 100644 --- a/crates/client-web/src/app/routes.ts +++ b/crates/client-web/src/app/routes.ts @@ -31,6 +31,7 @@ export function parseRoute(): AppRoute { const normalizedPath = normalizePath(globalThis.location.pathname); if (normalizedPath === '/podcasts') return { page: 'podcasts' }; + if (normalizedPath === '/live-tv') return { page: 'live-tv' }; const settingsMatch = /^\/settings(?:\/(libraries|providers|scheduled|users|dashboard|logs))?$/.exec(normalizedPath); if (settingsMatch) { diff --git a/crates/client-web/src/app/types.ts b/crates/client-web/src/app/types.ts index d86075ba..901cb630 100644 --- a/crates/client-web/src/app/types.ts +++ b/crates/client-web/src/app/types.ts @@ -32,6 +32,7 @@ export type AppRoute = | { page: 'item'; itemId: number } | { page: 'person'; personId: number } | { page: 'podcasts' } + | { page: 'live-tv' } | { page: 'settings'; section?: SettingsSection }; /** Top-level tabs shown on the home browsing surface. */ @@ -105,6 +106,8 @@ declare global { /** Mutable state for the browser client between render passes. */ export interface AppState { + /** Live channels and personal DVR data are scoped to the current account. */ + liveTv?: import('./liveTvActions').LiveTvState; apiBase: string; apiMode: ApiMode; route: AppRoute; diff --git a/crates/client-web/src/liveTvApi.ts b/crates/client-web/src/liveTvApi.ts new file mode 100644 index 00000000..5ac3a387 --- /dev/null +++ b/crates/client-web/src/liveTvApi.ts @@ -0,0 +1,106 @@ +/** Defines Live TV guide, setup, playback, and personal DVR API contracts. */ +import { requestJson, resolveApiUrl, getStoredAuthToken } from './api'; + +/** A playable channel; upstream stream credentials are never returned. */ +export interface TvChannel { + id: number; source_id: number; source_key: string; name: string; number: string; + group_name: string; logo?: string | null; guide_key: string; guide_override?: string | null; + enabled: boolean; available: boolean; +} +/** Provider metadata retained with both guide programs and recordings. */ +export interface TvMetadata { + subtitle?: string | null; description?: string | null; categories?: string[]; image?: string | null; + rating?: string | null; episode?: string | null; episode_id?: string | null; credits?: string[]; is_new?: boolean | null; +} +/** A scheduled broadcast using UTC seconds. */ +export interface TvProgram { + id: number; source_id: number; guide_key: string; starts_at: number; ends_at: number; + title: string; metadata: TvMetadata; +} +/** A guide station that can be assigned explicitly to a tuner or IPTV channel. */ +export interface TvStation { source_id: number; guide_key: string; name: string; number: string; aliases: string[]; logo?: string | null } +/** An administrator's configured tuner or playlist and refresh health. */ +export interface TvSource { id: number; name: string; kind: string; enabled: boolean; capacity: number; next_refresh: number; last_error?: string | null } +/** Source connection, guide, and optional native tuner settings supplied during setup. */ +export interface TvSourceConfig { + url: string; pin: string; xmltv_url: string; guide_kind: string; + adapter?: number; frontend?: number; zap_path?: string; multiplex_urls?: string[]; + gracenote?: TvGuideSelection; refresh_hours?: number; + native_device?: string; delivery_system?: string; region?: string; tuning_options?: string; +} +/** Discover driver-backed tuners attached to the Koko server. */ +export function discoverTvTuners(): Promise<{ id: string; name: string; info: string }[]> { return requestJson('GET', '/api/v1/live-tv/tuners'); } +/** Scan a local tuner's terrestrial channels again. */ +export function rescanTvTuner(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/sources/${id}/scan`); } +/** Selected Gracenote lineup and country/postal lookup context. */ +export interface TvGuideSelection { country: string; postal_code: string; lineup_id: string; headend_id: string; device: string; timezone: string; days: number } +/** A postal-code guide provider, including OTA, cable, or satellite. */ +export interface TvGuideProvider { type: string; device: string; lineupId: string; name: string; location: string; timezone: string; headendId: string } +/** Administrator-controlled storage and per-account DVR permissions. */ +export interface TvDvrSettings { settings: { recording_dir: string }; users: { user_id: number; username: string; can_record: boolean }[] } +/** Find guide providers available for an American or Canadian postal code. */ +export function findTvGuideProviders(country: string, postalCode: string): Promise { return requestJson('GET', `/api/v1/live-tv/guide/providers?country=${encodeURIComponent(country)}&postal_code=${encodeURIComponent(postalCode)}`); } +/** Read DVR settings visible only to administrators. */ +export function getTvDvrSettings(): Promise { return requestJson('GET', '/api/v1/live-tv/dvr/settings'); } +/** Change the save location for future recordings. */ +export function setTvDvrSettings(recordingDir: string): Promise { return requestJson('PUT', '/api/v1/live-tv/dvr/settings', { recording_dir: recordingDir }); } +/** Allow or deny scheduling for one account. */ +export function setTvRecordingPermission(userId: number, canRecord: boolean): Promise { return requestJson('PUT', `/api/v1/live-tv/dvr/permissions/${userId}`, { can_record: canRecord }); } +/** A supported source and its simultaneous stream allowance. */ +export interface TvSourceInput { name: string; kind: string; capacity: number; config: TvSourceConfig } +/** A recording timer or completed capture belonging to one account. */ +export interface TvRecording { + id: number; user_id: number; channel_id: number; rule_id?: number | null; title: string; + starts_at: number; ends_at: number; padding_before: number; padding_after: number; + state: string; has_video: boolean; last_error?: string | null; metadata: TvMetadata; + shared: boolean; +} +/** A series reservation on a selected channel. */ +export interface TvSeriesRule { id: number; user_id: number; channel_id: number; title: string; new_only: boolean; enabled: boolean; shared: boolean } +/** A one-off timer or request for a recurring series. */ +export interface TvSchedule { + channel_id: number; title: string; starts_at: number; ends_at: number; series: boolean; + new_only: boolean; padding_before: number; padding_after: number; + private?: boolean; +} +/** Read enabled channels or the administrator's complete channel list. */ +export function getTvChannels(setup = false): Promise { return requestJson('GET', `/api/v1/live-tv/${setup ? 'setup/' : ''}channels`); } +/** Fetch at most seven days of schedules and available guide station mappings. */ +export function getTvGuide(start: number, end: number): Promise<{ programs: TvProgram[]; stations: TvStation[] }> { return requestJson('GET', `/api/v1/live-tv/guide?start=${start}&end=${end}`); } +/** Read administrator source health. */ +export function getTvSources(): Promise { return requestJson('GET', '/api/v1/live-tv/sources'); } +/** Read recordings and scheduling conflicts visible to the signed-in account. */ +export function getTvRecordings(): Promise<{ recordings: TvRecording[]; conflicts: number[]; can_record: boolean }> { return requestJson('GET', '/api/v1/live-tv/recordings'); } +/** Read the signed-in account's recurring rules. */ +export function getTvRules(): Promise { return requestJson('GET', '/api/v1/live-tv/rules'); } +/** Save a source and report guide or channel refresh errors without losing its configuration. */ +export function addTvSource(source: TvSourceInput): Promise<{ id: number; refresh_error?: string | null }> { return requestJson('POST', '/api/v1/live-tv/sources', source, 180000); } +/** Replace all source connection settings while preserving channels and DVR schedules. */ +export function replaceTvSource(id: number, source: TvSourceInput): Promise { return requestJson('PUT', `/api/v1/live-tv/sources/${id}/connection`, source, 180000); } +/** Refresh a configured source immediately. */ +export function refreshTvSource(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/sources/${id}/refresh`, undefined, 180000); } +/** Update source availability and stream capacity. */ +export function setTvSource(source: Pick): Promise { return requestJson('PUT', `/api/v1/live-tv/sources/${source.id}`, source); } +/** Set channel visibility and an optional explicit guide station. */ +export function setTvChannel(id: number, enabled: boolean, guideOverride: string | null): Promise { return requestJson('PUT', `/api/v1/live-tv/channels/${id}`, { enabled, guide_override: guideOverride }); } +/** Reserve one broadcast or a recurring series. */ +export function scheduleTvRecording(timer: TvSchedule): Promise { return requestJson('POST', '/api/v1/live-tv/recordings', timer); } +/** Stop a capture or cancel a timer while retaining any saved video. */ +export function cancelTvRecording(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/recordings/${id}/cancel`); } +/** Delete the current account's stopped recording and saved video. */ +export function deleteTvRecording(id: number): Promise { return requestJson('DELETE', `/api/v1/live-tv/recordings/${id}`); } +/** Remove a recurring series rule and cancel its future timers. */ +export function deleteTvRule(id: number): Promise { return requestJson('DELETE', `/api/v1/live-tv/rules/${id}`); } +/** Resolve authenticated same-server video URLs without including credentials in the URL. */ +export function tvStreamUrl(kind: 'channels' | 'recordings', id: number): string { return resolveApiUrl(`/api/v1/live-tv/${kind}/${id}/stream`); } +/** Upload XMLTV directly when the guide is stored on the user's computer. */ +export async function uploadTvGuide(id: number, file: File): Promise { + const token = getStoredAuthToken(); + const response = await fetch(resolveApiUrl(`/api/v1/live-tv/sources/${id}/xmltv`), { + method: 'POST', credentials: 'include', body: file, headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})) as { message?: string }; + throw new Error(body.message ?? 'The XMLTV guide could not be imported.'); + } +} diff --git a/crates/client-web/tests/e2e/live-tv.spec.mjs b/crates/client-web/tests/e2e/live-tv.spec.mjs new file mode 100644 index 00000000..5e6fe15b --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv.spec.mjs @@ -0,0 +1,117 @@ +/** Exercises real IPTV import, browser decoding, tuner release, and timed DVR capture. */ +import { test, expect } from '@playwright/test'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; + +test('IPTV imports its guide, plays live video, releases the tuner, and saves a timed recording', async ({ page }, testInfo) => { + test.setTimeout(180000); + const children = new Set(); + let activeStreams = 0; + let sourceId; + let headers; + const stamp = seconds => new Date(seconds * 1000).toISOString().slice(0, 19).replaceAll(/[-T:]/g, '') + ' +0000'; + const fixture = createServer((request, response) => { + const base = `http://127.0.0.1:${fixture.address().port}`; + if (request.url === '/tv.m3u') { + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="test-tv" tvg-chno="7.1" group-title="News",Live Test TV\n${base}/stream.ts\n#EXTINF:-1 tvg-id="hls-tv" tvg-chno="7.2",HLS Test TV\n${base}/master.m3u8\n`); + } else if (request.url === '/master.m3u8') { + response.setHeader('Content-Type', 'application/octet-stream'); + response.write('#EXT'); + setImmediate(() => response.end('M3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360\nhls.m3u8\n')); + } else if (request.url === '/hls.m3u8') { + response.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + response.end('#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2,\nsegment0.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:2,\nsegment1.ts\n#EXT-X-ENDLIST\n'); + } else if (request.url === '/guide.xml') { + const now = Math.floor(Date.now() / 1000); + response.setHeader('Content-Type', 'application/xml'); + response.end(`Live Test TVLive Test NewsLocal IPTV guide fixture.News`); + } else if (request.url === '/stream.ts' || /^\/segment\d+\.ts$/.test(request.url)) { + activeStreams += 1; + const ffmpeg = spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG ?? process.env.KOKO_E2E_FFMPEG ?? 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-re', + '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-c:v', 'mpeg4', '-q:v', '5', '-g', '25', '-c:a', 'aac', '-f', 'mpegts', ...(request.url === '/stream.ts' ? [] : ['-t', '2']), 'pipe:1'], + { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); + children.add(ffmpeg); + response.setHeader('Content-Type', 'video/mp2t'); + ffmpeg.stdout.pipe(response); + response.once('close', () => { activeStreams -= 1; ffmpeg.kill(); children.delete(ffmpeg); }); + ffmpeg.once('error', () => response.destroy()); + } else { response.statusCode = 404; response.end(); } + }); + await new Promise(done => fixture.listen(0, '127.0.0.1', done)); + try { + await page.goto('/'); + await page.getByLabel('Username', { exact: true }).fill('playback-admin'); + await page.getByLabel('Password', { exact: true }).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + const token = await page.evaluate(() => localStorage.getItem('koko-client-web-auth-token')); + headers = { Authorization: `Bearer ${token}` }; + const base = `http://127.0.0.1:${fixture.address().port}`; + const created = await page.request.post('/api/v1/live-tv/sources', { headers, data: { + name: 'Local IPTV test', kind: 'm3u', capacity: 1, + config: { url: `${base}/tv.m3u`, guide_kind: 'xmltv', xmltv_url: `${base}/guide.xml` }, + } }); + expect(created.ok()).toBeTruthy(); sourceId = (await created.json()).id; + let channel; + await expect.poll(async () => { + const response = await page.request.get('/api/v1/live-tv/channels', { headers }); + channel = (await response.json()).find(c => c.source_id === sourceId && c.name === 'Live Test TV'); + return channel?.name; + }, { timeout: 45000 }).toBe('Live Test TV'); + expect(channel).not.toHaveProperty('stream_url'); + await page.goto('/live-tv'); + await expect(page.getByText('Live Test News', { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('live-tv-guide.png') }); + const channels = await (await page.request.get('/api/v1/live-tv/channels', { headers })).json(); + const hls = channels.find(c => c.source_id === sourceId && c.name === 'HLS Test TV'); + await page.locator(`[data-tv-play="${hls.id}"]`).click(); + await expect.poll(() => page.locator('.live-tv-player video').evaluate(video => video.readyState >= 2 && video.currentTime > 0.1)).toBeTruthy(); + await page.getByRole('button', { name: 'Close TV player' }).click(); + await expect.poll(() => activeStreams).toBe(0); + await page.locator(`[data-tv-play="${channel.id}"]`).click(); + await expect.poll(() => page.locator('.live-tv-player video').evaluate(video => video.readyState >= 2 && video.currentTime > 0.2)).toBeTruthy(); + expect(activeStreams).toBe(1); + await page.getByRole('button', { name: 'Close TV player' }).click(); + await expect.poll(() => activeStreams).toBe(0); + + const recordingDirectory = testInfo.outputPath('dvr-storage'); + const storage = await page.request.put('/api/v1/live-tv/dvr/settings', { headers, data: { recording_dir: recordingDirectory } }); + expect(storage.ok()).toBeTruthy(); + const now = Math.floor(Date.now() / 1000); + const scheduled = await page.request.post('/api/v1/live-tv/recordings', { headers, data: { + channel_id: channel.id, title: 'Timed IPTV recording', starts_at: now - 1, ends_at: now + 12, + series: false, new_only: false, padding_before: 0, padding_after: 0, + } }); + expect(scheduled.ok()).toBeTruthy(); + let recording; + await expect.poll(async () => { + const response = await page.request.get('/api/v1/live-tv/recordings', { headers }); + recording = (await response.json()).recordings.find(r => r.title === 'Timed IPTV recording'); + return recording?.state; + }, { timeout: 35000 }).toBe('completed'); + expect(recording.has_video).toBeTruthy(); + expect(recording.shared).toBeTruthy(); + const savedFile = testInfo.outputPath('dvr-storage', `recording-${recording.id}.ts`); + expect((await stat(savedFile)).size).toBeGreaterThan(0); + // Changing the folder for future captures must not move or break the completed video. + const changedStorage = await page.request.put('/api/v1/live-tv/dvr/settings', { headers, data: { recording_dir: '' } }); + expect(changedStorage.ok()).toBeTruthy(); + await page.getByRole('button', { name: 'Recordings', exact: true }).click(); + await expect(page.getByText('Timed IPTV recording', { exact: true })).toBeVisible(); + await page.locator(`[data-tv-recording-play="${recording.id}"]`).click(); + await expect.poll(() => page.locator('.live-tv-player video').evaluate(video => video.readyState >= 2 && video.currentTime > 0.1)).toBeTruthy(); + await page.getByRole('button', { name: 'Close TV player' }).click(); + const unauthenticated = await page.request.get('/api/v1/live-tv/recordings', { headers: { Authorization: 'Bearer invalid' } }); + expect(unauthenticated.status()).toBe(401); + const deleted = await page.request.delete(`/api/v1/live-tv/recordings/${recording.id}`, { headers }); + expect(deleted.ok(), await deleted.text()).toBeTruthy(); + await expect(stat(savedFile)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + if (sourceId && headers) await page.request.put(`/api/v1/live-tv/sources/${sourceId}`, { headers, data: { enabled: false, capacity: 1 } }); + for (const child of children) child.kill(); + fixture.closeAllConnections(); + await new Promise(done => fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/live-tv.test.mjs b/crates/client-web/tests/live-tv.test.mjs new file mode 100644 index 00000000..b452e08b --- /dev/null +++ b/crates/client-web/tests/live-tv.test.mjs @@ -0,0 +1,96 @@ +/** Tests account isolation, station mapping, safe metadata rendering, and guide request races. */ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +globalThis.location = { pathname: '/live-tv', origin: 'https://koko.local' }; +globalThis.localStorage = { getItem: () => null }; +const server = await createServer({ root: fileURLToPath(new URL('..', import.meta.url)), server: { middlewareMode: true, hmr: false, ws: false } }); +after(() => server.close()); +const { channelPrograms, renderLiveTvPage, tvImage } = await server.ssrLoadModule('/src/app/liveTv.ts'); +const { loadLiveTv } = await server.ssrLoadModule('/src/app/liveTvActions.ts'); +const { state } = await server.ssrLoadModule('/src/app/state.ts'); +const { parseRoute } = await server.ssrLoadModule('/src/app/routes.ts'); +const now = Math.floor(Date.now() / 1000); +const channel = { id: 1, source_id: 1, source_key: 'source', name: 'Test TV', number: '7.1', group_name: 'News', guide_key: 'station', enabled: true, available: true }; +const program = { id: 1, source_id: 1, guide_key: 'station', title: 'News', starts_at: now - 30, ends_at: now + 3600, metadata: { description: 'Evening news', categories: ['News'], credits: ['Presenter'] } }; +function data(extra = {}) { + return { owner: 1, channels: [channel], programs: [program], stations: [], recordings: [], rules: [], sources: [], conflicts: [], windowStart: now, tab: 'guide', loading: false, ...extra }; +} +function app(extra = {}) { return { bootstrap: { current_user: { id: 1, admin: false } }, route: { page: 'live-tv' }, liveTv: data(), ...extra }; } +function api(extra = {}) { + return { getTvChannels: async () => [channel], getTvGuide: async () => ({ programs: [program], stations: [] }), getTvRecordings: async () => ({ recordings: [], conflicts: [] }), getTvRules: async () => [], getTvSources: async () => [], ...extra }; +} + +test('live television has its own persistent route', () => { assert.deepEqual(parseRoute(), { page: 'live-tv' }); }); +test('guide mappings require the correct source and honor explicit overrides', () => { + const rows = [program, { ...program, id: 2, source_id: 2 }, { ...program, id: 3, guide_key: 'override' }]; + assert.deepEqual(channelPrograms(channel, rows).map(p => p.id), [1]); + assert.deepEqual(channelPrograms({ ...channel, guide_override: 'override' }, rows).map(p => p.id), [3]); +}); +test('broadcast metadata is escaped and active artwork URLs never become image elements', () => { + state.bootstrap = { current_user: { id: 1, admin: false } }; + state.liveTv = data({ programs: [{ ...program, title: '', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); + const html = renderLiveTvPage(); + assert.match(html, /<script>/); assert.match(html, /<img/); assert.doesNotMatch(html, /javascript:|', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); const html = renderLiveTvPage(); + const dom = new JSDOM(html); + assert.ok(dom.window.document.querySelector('.home-pinned-header > .home-feature > .home-feature-copy')); + assert.ok(dom.window.document.querySelector('.home-navbar .browse-tabs')); + assert.equal(dom.window.document.querySelector('.tv-guide-hero'),null); + dom.window.close(); assert.match(html, /<script>/); assert.match(html, /<img/); assert.doesNotMatch(html, /javascript:|', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); diff --git a/crates/client-web/vite.config.ts b/crates/client-web/vite.config.ts index e9e0a9ef..9560b019 100644 --- a/crates/client-web/vite.config.ts +++ b/crates/client-web/vite.config.ts @@ -39,14 +39,15 @@ function serveBackendAssets(server: ViteDevServer | PreviewServer): void { const assetDirectory = new URL('../../assets/providers/', import.meta.url); server.middlewares.use('/assets/providers', async (request, response) => { const filename = new URL(request.url ?? '/', 'http://localhost').pathname.slice(1); - if (!/^[a-z0-9-]+\.(svg|png)$/.test(filename)) { + if (!/^[a-z0-9-]+\.(svg|png|ico)$/.test(filename)) { response.statusCode = 404; response.end(); return; } try { const bytes = await readFile(new URL(filename, assetDirectory)); - response.setHeader('Content-Type', filename.endsWith('.svg') ? 'image/svg+xml' : 'image/png'); + const contentTypes:Record={svg:'image/svg+xml',ico:'image/x-icon',png:'image/png'}; + response.setHeader('Content-Type', contentTypes[filename.split('.').at(-1)!]); response.end(bytes); } catch { response.statusCode = 404; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 544cdc77..c18b77cb 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -72,6 +72,7 @@ md5 = "=0.7.0" mdns-sd = "=0.21.6" musicbrainz_rs = { version = "=0.14.0", features = ["async", "rustls"] } once_cell = "=1.21.4" +quick-xml = "=0.42.0" rand = "=0.10.3" rcgen = "=0.14.10" regex = "=1.13.1" @@ -88,6 +89,7 @@ serde_yaml = "=0.9.34" sha2 = "=0.11.1" strsim = "=0.11.1" tao = { version = "=0.37.2", optional = true } +tempfile = "=3.27.0" tokio = { version = "=1.54.1", features = ["full"] } tmdb_client = "=1.8.0" tray-icon = { version = "=0.26.1", optional = true } diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index a9365b44..45d7e357 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -294,6 +294,7 @@ impl<'r> FromRequest<'r> for Claims { || ((path.starts_with("/api/v1/live-tv/programs/") || path.starts_with("/api/v1/live-tv/channels/")) && path.ends_with("/artwork")) + || (path.starts_with("/api/v1/live-tv/sources/") && path.ends_with("/image")) || path.starts_with("/api/v1/user-profile-images/")); let token = if let Some(header) = keys.first() { header.strip_prefix(BEARER) diff --git a/crates/server/src/live_tv/dvr.rs b/crates/server/src/live_tv/dvr.rs index f6d7a551..d52f7435 100644 --- a/crates/server/src/live_tv/dvr.rs +++ b/crates/server/src/live_tv/dvr.rs @@ -89,6 +89,10 @@ pub(crate) fn schedule( if !source.enabled { return Err("This source is disabled".into()); } + let provider = super::providers::load(conn, source.id).map_err(super::db_error)?; + if !super::providers::allowed(&source, &channel, &provider) { + return Err("This provider is hidden".into()); + } let program = storage::guide(conn, request.starts_at, request.ends_at) .map_err(super::db_error)? .into_iter() diff --git a/crates/server/src/live_tv/feeds.rs b/crates/server/src/live_tv/feeds.rs new file mode 100644 index 00000000..f65e3353 --- /dev/null +++ b/crates/server/src/live_tv/feeds.rs @@ -0,0 +1,147 @@ +//! Disk-backed playlist and XMLTV transfers, without fixed feed size or entry-count caps. + +use super::{ + Channel, + formats, + sources, +}; +use std::io::{ + BufRead, + BufReader, + Seek, + SeekFrom, +}; +use tokio::io::AsyncWriteExt; + +/// The OS removes this anonymous file when its last handle closes, including on failure. +pub(crate) fn temporary() -> Result { + tempfile::tempfile() + .map(tokio::fs::File::from_std) + .map_err(|_| { + "Unable to create a temporary TV feed file; check free space and permissions".into() + }) +} + +async fn download(url: reqwest::Url) -> Result { + download_file(url, false) + .await? + .ok_or_else(|| "The TV feed is unavailable".into()) +} + +async fn download_file( + url: reqwest::Url, + optional_bridge: bool, +) -> Result, String> { + // An idle transfer should fail, but a large feed that keeps delivering data should + // not be cut off by the short total timeout used for tuner control requests. + let client = sources::client_builder() + .read_timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|_| "Unable to create TV feed client")?; + let mut response = client + .get(url) + .send() + .await + .map_err(|_| "Unable to connect to the TV or guide source")?; + // FastChannels explicitly returns Conflict when PrismCast is not configured. A warming or + // failed export must still fail the import, preserving the previously imported lineup. + if optional_bridge && response.status() == reqwest::StatusCode::CONFLICT { + return Ok(None); + } + response = response.error_for_status().map_err(|_| { + "The TV or guide source returned an error; check its address and credentials" + })?; + let mut file = temporary()?; + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "The TV source transfer failed")? + { + file.write_all(&chunk) + .await + .map_err(|_| "Unable to save the TV feed; check temporary disk space")?; + } + file.flush() + .await + .map_err(|_| "Unable to save the TV feed; check temporary disk space")?; + Ok(Some(file.into_std().await)) +} + +fn reader(mut file: std::fs::File) -> Result, String> { + file.seek(SeekFrom::Start(0)) + .map_err(|_| "Unable to read the temporary TV feed")?; + let mut input = BufReader::with_capacity(64 * 1024, file); + if input + .fill_buf() + .map_err(|_| "Unable to read the TV feed")? + .starts_with(&[0x1f, 0x8b]) + { + // Handle .gz downloads without Content-Encoding as well as concatenated gzip members. + Ok(Box::new(BufReader::new( + flate2::bufread::MultiGzDecoder::new(input), + ))) + } else { + Ok(Box::new(input)) + } +} + +pub(super) async fn playlist_input(url: reqwest::Url) -> Result<(Vec, u64), String> { + playlist_details(url, false, false) + .await + .map(|(p, size)| (p.channels, size)) +} + +pub(super) async fn playlist_details( + url: reqwest::Url, + stable_ids: bool, + allow_empty: bool, +) -> Result<(formats::Playlist, u64), String> { + let bridge = stable_ids && allow_empty && url.path().contains("/prismcast"); + let Some(file) = download_file(url.clone(), bridge).await? else { + return Ok(( + formats::Playlist { + channels: vec![], + identities: Default::default(), + }, + 0, + )); + }; + let size = file + .metadata() + .map_err(|_| "Unable to read the TV feed size")? + .len(); + let channels = tokio::task::spawn_blocking(move || { + formats::read_playlist(reader(file)?, url.as_str(), stable_ids, allow_empty) + }) + .await + .map_err(|_| "Unable to process the channel playlist")??; + Ok((channels, size)) +} + +pub(super) async fn guide_input(url: reqwest::Url) -> Result<(formats::Guide, u64), String> { + let file = download(url).await?; + let size = file + .metadata() + .map_err(|_| "Unable to read the guide size")? + .len(); + Ok((read_guide(file).await?, size)) +} + +#[cfg(test)] +pub(super) async fn playlist(url: reqwest::Url) -> Result, String> { + playlist_input(url).await.map(|v| v.0) +} + +#[cfg(test)] +pub(super) async fn guide(url: reqwest::Url) -> Result { + guide_input(url).await.map(|v| v.0) +} + +pub(super) async fn read_guide(file: std::fs::File) -> Result { + tokio::task::spawn_blocking(move || formats::read_guide(reader(file)?, Some(super::now()))) + .await + .map_err(|_| "Unable to process the XMLTV guide")? +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/live_tv/feeds/tests.rs b/crates/server/src/live_tv/feeds/tests.rs new file mode 100644 index 00000000..29f8f1b4 --- /dev/null +++ b/crates/server/src/live_tv/feeds/tests.rs @@ -0,0 +1,259 @@ +//! Large and compressed feed transfers, independent of an external TV provider. + +use super::*; +use std::io::{ + Read, + Write, +}; + +fn serve( + mut file: std::fs::File, + encoding: &str, + chunked: bool, +) -> (reqwest::Url, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/feed", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let encoding = encoding.to_owned(); + let task = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut request = [0; 8192]; + assert!(socket.read(&mut request).unwrap() > 0); + write!(socket, "HTTP/1.1 200 OK\r\nConnection: close\r\n{encoding}").unwrap(); + if chunked { + write!(socket, "Transfer-Encoding: chunked\r\n\r\n").unwrap(); + } else { + write!( + socket, + "Content-Length: {}\r\n\r\n", + file.metadata().unwrap().len() + ) + .unwrap(); + } + file.rewind().unwrap(); + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + if chunked { + write!(socket, "{count:x}\r\n").unwrap(); + } + socket.write_all(&buffer[..count]).unwrap(); + if chunked { + socket.write_all(b"\r\n").unwrap(); + } + } + if chunked { + socket.write_all(b"0\r\n\r\n").unwrap(); + } + }); + (url, task) +} + +#[rocket::async_test] +async fn xmltv_downloads_exceed_64_mib_with_plain_and_both_gzip_transports() { + let mut file = tempfile::tempfile().unwrap(); + let now = chrono::Utc::now(); + let stop = now + chrono::Duration::hours(1); + write!( + file, + "TVLarge feedNews \ + & weather", + now.format("%Y%m%d%H%M%S"), + stop.format("%Y%m%d%H%M%S") + ) + .unwrap(); + // Repeated, modest-sized entries exercise streaming without a huge XML token. + let padding = format!("", "x".repeat(1017)).repeat(64); + for _ in 0..1040 { + file.write_all(padding.as_bytes()).unwrap(); + } + file.write_all(b"").unwrap(); + assert!(file.metadata().unwrap().len() > 64 * 1024 * 1024); + let compressed = tempfile::tempfile().unwrap(); + let mut encoder = flate2::write::GzEncoder::new(compressed, flate2::Compression::fast()); + file.rewind().unwrap(); + std::io::copy(&mut file, &mut encoder).unwrap(); + let compressed = encoder.finish().unwrap(); + for (input, encoding, chunked) in [ + (&file, "", false), + (&compressed, "", true), + (&compressed, "Content-Encoding: gzip\r\n", true), + ] { + let (url, task) = serve(input.try_clone().unwrap(), encoding, chunked); + let guide = guide(url).await.unwrap(); + task.join().unwrap(); + assert_eq!(guide.programs.len(), 1); + assert_eq!( + guide.programs[0].metadata.description.as_deref(), + Some("News & weather") + ); + assert_eq!(guide.stations[0].guide_key, "station"); + } +} + +#[rocket::async_test] +async fn gzip_upload_reader_handles_members_and_rejects_a_truncated_trailer() { + let mut file = tempfile::tempfile().unwrap(); + for part in [ + b"".as_slice(), + b"TV", + ] { + let mut encoder = flate2::write::GzEncoder::new(&mut file, flate2::Compression::fast()); + encoder.write_all(part).unwrap(); + encoder.finish().unwrap(); + } + let guide = read_guide(file.try_clone().unwrap()).await.unwrap(); + assert_eq!(guide.stations.len(), 1); + file.set_len(file.metadata().unwrap().len() - 4).unwrap(); + assert!(read_guide(file).await.is_err()); +} + +#[test] +fn xmltv_has_no_program_or_document_node_count_cap() { + let mut file = std::io::BufWriter::new(tempfile::tempfile().unwrap()); + file.write_all(b"").unwrap(); + let now = chrono::Utc::now(); + for index in 0..250_001 { + let start = now + chrono::Duration::seconds(index); + let stop = start + chrono::Duration::seconds(1); + write!( + file, + "Program \ + {index}SportsTVNewsMovieKidsGuide \ + entry", + start.format("%Y%m%d%H%M%S"), + stop.format("%Y%m%d%H%M%S") + ) + .unwrap(); + } + file.write_all(b"").unwrap(); + let file = file.into_inner().unwrap(); + let guide = formats::read_guide(reader(file).unwrap(), Some(now.timestamp())).unwrap(); + assert_eq!(guide.programs.len(), 250_001); + assert_eq!(guide.programs.last().unwrap().title, "Program 250000"); +} + +#[test] +fn playlists_have_no_20_000_channel_cap_and_keep_duplicate_detection() { + let mut file = std::io::BufWriter::new(tempfile::tempfile().unwrap()); + file.write_all(b"\xef\xbb\xbf\n\n#EXTM3U\n").unwrap(); + for index in 0..20_001 { + writeln!( + file, + "#EXTINF:-1 tvg-id=\"{index}\",TV {index}\n/{index}.ts" + ) + .unwrap(); + } + file.write_all(b"#EXTINF:-1 tvg-id=\"0\",TV 0\n/0.ts\n") + .unwrap(); + let channels = formats::read_m3u( + reader(file.into_inner().unwrap()).unwrap(), + "https://example.com/tv/list.m3u", + ) + .unwrap(); + assert_eq!(channels.len(), 20_001); + assert_eq!( + channels.last().unwrap().stream_url, + "https://example.com/20000.ts" + ); +} + +#[test] +fn xmltv_rejects_incomplete_documents_custom_entities_and_multiple_roots() { + for xml in [ + "", + "", + "", + "", + "", + "]>", + "&custom;", + "", + "trailing text", + ] { + assert!(formats::read_guide(xml.as_bytes(), None).is_err(), "{xml}"); + } + let xml = concat!( + "", + "" + ) + .as_bytes(); + assert_eq!( + formats::read_guide(xml, None).unwrap().stations[0].name, + "TV & news" + ); +} + +#[test] +fn streamed_entries_keep_root_namespaces_and_infer_stops_across_retention_boundaries() { + let now = chrono::Utc::now(); + let start = now + chrono::Duration::seconds(crate::live_tv::retention::FUTURE - 60); + let next = start + chrono::Duration::seconds(120); + let old = now - chrono::Duration::seconds(crate::live_tv::retention::HISTORY + 3600); + let stamp = |time: chrono::DateTime| time.format("%Y%m%d%H%M%S +0000").to_string(); + let xml = format!( + "OutsideRetainedA & \ + BExpired", + stamp(next), + stamp(next + chrono::Duration::hours(1)), + stamp(start), + stamp(old), + stamp(old + chrono::Duration::minutes(30)) + ); + let guide = formats::read_guide(xml.as_bytes(), Some(now.timestamp())).unwrap(); + assert_eq!(guide.programs.len(), 1); + assert_eq!(guide.programs[0].title, "Retained"); + assert_eq!(guide.programs[0].ends_at, next.timestamp()); + assert_eq!( + guide.programs[0].metadata.description.as_deref(), + Some("A & B") + ); +} + +#[rocket::async_test] +#[ignore = "Requires KOKO_TEST_M3U_URL and KOKO_TEST_XMLTV_URL for a real provider"] +async fn provider_feed_import_round_trip() { + use crate::db::schema::live_tv_programs as programs; + use diesel::prelude::*; + let started = std::time::Instant::now(); + let channels = playlist(std::env::var("KOKO_TEST_M3U_URL").unwrap().parse().unwrap()) + .await + .unwrap(); + let guide = guide( + std::env::var("KOKO_TEST_XMLTV_URL") + .unwrap() + .parse() + .unwrap(), + ) + .await + .unwrap(); + assert!(!channels.is_empty()); + assert!(!guide.programs.is_empty()); + let mut conn = crate::live_tv::tests::database(); + crate::live_tv::storage::save_channels(&mut conn, 1, &channels).unwrap(); + crate::live_tv::storage::save_guide(&mut conn, 1, &guide).unwrap(); + let count = programs::table + .filter(programs::source_id.eq(1)) + .count() + .get_result::(&mut conn) + .unwrap(); + assert_eq!(count as usize, guide.programs.len()); + eprintln!( + "Imported {} channels, {} stations, {count} programs in {:.1}s", + channels.len(), + guide.stations.len(), + started.elapsed().as_secs_f64() + ); +} diff --git a/crates/server/src/live_tv/formats.rs b/crates/server/src/live_tv/formats.rs index 2a9aa929..af62d5e3 100644 --- a/crates/server/src/live_tv/formats.rs +++ b/crates/server/src/live_tv/formats.rs @@ -1,4 +1,6 @@ -//! Bounded playlist and XMLTV parsing without resolving external XML entities. +//! Channel playlist parsing and shared guide data types. + +mod xmltv; use rocket_okapi::okapi::schemars::{ self, @@ -119,76 +121,13 @@ pub(super) fn endpoint( .map_err(|_| "Invalid TV server address".into()) } +#[cfg(test)] pub(super) fn parse_guide(value: &str) -> Result { - let programs = xmltv(value)?; - let doc = roxmltree::Document::parse_with_options( - value, - roxmltree::ParsingOptions { - allow_dtd: true, - nodes_limit: 3_000_000, - ..Default::default() - }, - ) - .map_err(|_| "Invalid XMLTV guide")?; - let mut stations = Vec::new(); - for node in doc - .root_element() - .children() - .filter(|n| n.has_tag_name("channel")) - { - let Some(key) = node.attribute("id") else { - continue; - }; - let aliases = node - .children() - .filter(|n| n.has_tag_name("display-name")) - .filter_map(|n| n.text().map(str::to_owned)) - .collect::>(); - let number = child_text(node, "lcn") - .or_else(|| { - aliases - .iter() - .find(|s| { - s.chars() - .all(|c| c.is_ascii_digit() || c == '.' || c == '-') - }) - .cloned() - }) - .unwrap_or_default(); - stations.push(Station { - source_id: 0, - guide_key: key.into(), - name: aliases.first().cloned().unwrap_or_else(|| key.into()), - number, - aliases, - logo: node - .children() - .find(|n| n.has_tag_name("icon")) - .and_then(|n| n.attribute("src")) - .filter(|url| http_url(url).is_ok()) - .map(str::to_owned), - }); - } - // Some minimal guides omit channel declarations. Keep their explicit station IDs mappable. - let mut keys = stations - .iter() - .map(|s| s.guide_key.clone()) - .collect::>(); - for p in &programs { - if keys.insert(p.guide_key.clone()) { - stations.push(Station { - source_id: 0, - guide_key: p.guide_key.clone(), - name: p.guide_key.clone(), - number: String::new(), - aliases: vec![], - logo: None, - }); - } - } - Ok(Guide { programs, stations }) + xmltv::read(value.as_bytes(), None) } +pub(super) use xmltv::read as read_guide; + pub(super) fn http_url(value: &str) -> Result { let url = reqwest::Url::parse(value.trim()).map_err(|_| "Enter a valid HTTP or HTTPS URL")?; if !matches!(url.scheme(), "http" | "https") @@ -222,27 +161,74 @@ fn display_separator(value: &str) -> Option { None } +#[cfg(test)] pub(super) fn m3u( value: &str, base: &str, ) -> Result, String> { + read_m3u(value.as_bytes(), base) +} + +#[cfg(test)] +pub(super) fn read_m3u( + input: impl std::io::BufRead, + base: &str, +) -> Result, String> { + read_playlist(input, base, false, false).map(|p| p.channels) +} + +pub(super) struct Playlist { + pub channels: Vec, + pub identities: HashMap, +} + +/// Preserve provider identity separately from guide IDs and optional stable FastChannels IDs. +pub(super) fn read_playlist( + mut input: impl std::io::BufRead, + base: &str, + stable_ids: bool, + allow_empty: bool, +) -> Result { let base = http_url(base)?; let mut rows = Vec::new(); + let mut identities = HashMap::new(); let mut pending: Option<(HashMap, String)> = None; let mut seen = HashSet::new(); - if !value + let mut line = String::new(); + loop { + if input + .read_line(&mut line) + .map_err(|_| "Unable to read the channel playlist")? + == 0 + { + return Err("The channel source is not an extended M3U playlist".into()); + } + let header = line.trim_start_matches('\u{feff}').trim(); + if !header.is_empty() { + break; + } + line.clear(); + } + if !line .trim_start_matches('\u{feff}') .trim_start() .starts_with("#EXTM3U") { return Err("The channel source is not an extended M3U playlist".into()); } - if value.lines().any(|line| { - line.starts_with("#EXT-X-TARGETDURATION") || line.starts_with("#EXT-X-STREAM-INF") - }) { - return Err("Enter a channel playlist, rather than an individual HLS stream".into()); - } - for line in value.lines().map(str::trim) { + loop { + line.clear(); + if input + .read_line(&mut line) + .map_err(|_| "Unable to read the channel playlist")? + == 0 + { + break; + } + let line = line.trim(); + if line.starts_with("#EXT-X-TARGETDURATION") || line.starts_with("#EXT-X-STREAM-INF") { + return Err("Enter a channel playlist, rather than an individual HLS stream".into()); + } if let Some(info) = line.strip_prefix("#EXTINF:") { let split = display_separator(info).ok_or("A channel entry has no display name")?; pending = Some(( @@ -257,9 +243,16 @@ pub(super) fn m3u( .join(line) .map_err(|_| "A channel has an invalid stream URL")?; http_url(url.as_str())?; - let guide_key = attrs.get("tvg-id").cloned().unwrap_or_default(); + let identity = attrs.get("channel-id").cloned().unwrap_or_default(); + let guide_key = attrs + .get("tvg-id") + .cloned() + .or_else(|| stable_ids.then(|| identity.clone())) + .unwrap_or_default(); // The URL distinguishes multiple qualities with the same EPG ID. - let key = if guide_key.is_empty() { + let key = if stable_ids && !identity.is_empty() { + format!("fastchannels:{identity}") + } else if guide_key.is_empty() { format!( "stream:{}", Sha256::digest(url.as_str().as_bytes()) @@ -273,6 +266,7 @@ pub(super) fn m3u( if !seen.insert(key.clone()) { continue; } + identities.insert(key.clone(), identity); let mut channel = Channel::new(key, name, url.to_string()); channel.guide_key = guide_key; channel.number = attrs.get("tvg-chno").cloned().unwrap_or_default(); @@ -282,15 +276,15 @@ pub(super) fn m3u( .filter(|v| http_url(v).is_ok()) .cloned(); rows.push(channel); - if rows.len() > 20_000 { - return Err("The playlist exceeds 20,000 channels".into()); - } } } - if rows.is_empty() { + if rows.is_empty() && !allow_empty { return Err("No playable channels were found in the playlist".into()); } - Ok(rows) + Ok(Playlist { + channels: rows, + identities, + }) } pub(super) fn xml_time(value: &str) -> Option { @@ -319,138 +313,3 @@ pub(super) fn child_text( .filter(|s| !s.is_empty()) .map(str::to_owned) } - -pub(super) fn xmltv(value: &str) -> Result, String> { - // Allow the conventional external DOCTYPE declaration; roxmltree never fetches it. - // Entity declarations are rejected to avoid expansion and unsupported custom entities. - if value.contains(" { - if let Some(text) = child.text() { - metadata.categories.push(text.to_owned()); - } - } - "icon" => { - metadata.image = child - .attribute("src") - .filter(|s| http_url(s).is_ok()) - .map(str::to_owned) - } - "rating" => metadata.rating = child_text(child, "value"), - "date" => { - metadata.year = child - .text() - .and_then(|value| value.get(..4)) - .and_then(|year| year.parse().ok()) - } - "episode-num" => { - let episode = child.text().map(str::to_owned); - if child.attribute("system") == Some("onscreen") { - metadata.episode = episode; - } else if metadata.episode_id.is_none() { - metadata.episode_id = episode.map(|id| { - format!("{}:{id}", child.attribute("system").unwrap_or("unknown")) - }); - } - if metadata.episode.is_none() && child.attribute("system") == Some("xmltv_ns") { - let parts = child - .text() - .unwrap_or_default() - .split('.') - .take(2) - .map(|part| { - part.split('/') - .next() - .unwrap_or_default() - .trim() - .parse::() - .ok() - .and_then(|number| number.checked_add(1)) - }) - .collect::>(); - if let [Some(season), Some(episode)] = parts.as_slice() { - metadata.episode = Some(format!("S{season}E{episode}")); - } - } - } - "new" => metadata.is_new = Some(true), - "premiere" => { - metadata.is_premiere = Some(true); - metadata.is_new = Some(true); - } - "live" => metadata.is_live = Some(true), - "finale" => metadata.is_finale = Some(true), - "previously-shown" => metadata.is_new = Some(false), - "credits" => { - metadata.credits = child - .children() - .filter_map(|c| c.text()) - .map(str::to_owned) - .collect() - } - _ => {} - } - } - rows.push(Program { - id: 0, - source_id: 0, - guide_key: key.into(), - starts_at: start, - ends_at: end.unwrap_or(0), - title, - metadata, - }); - if rows.len() > 250_000 { - return Err("The guide exceeds 250,000 programs".into()); - } - } - rows.sort_by(|a, b| (&a.guide_key, a.starts_at).cmp(&(&b.guide_key, b.starts_at))); - // XMLTV stop is optional. Infer it only from the next event on the same station. - for index in 0..rows.len() { - if rows[index].ends_at == 0 - && index + 1 < rows.len() - && rows[index].guide_key == rows[index + 1].guide_key - { - rows[index].ends_at = rows[index + 1].starts_at; - } - } - rows.retain(|p| p.ends_at > p.starts_at); - rows.dedup_by(|a, b| a.guide_key == b.guide_key && a.starts_at == b.starts_at); - Ok(rows) -} diff --git a/crates/server/src/live_tv/formats/xmltv.rs b/crates/server/src/live_tv/formats/xmltv.rs new file mode 100644 index 00000000..a8fa85c6 --- /dev/null +++ b/crates/server/src/live_tv/formats/xmltv.rs @@ -0,0 +1,344 @@ +//! Read XMLTV one top-level entry at a time, without building a guide-sized XML tree. + +use super::{ + Guide, + Program, + ProgramMetadata, + Station, + child_text, + http_url, + xml_time, +}; +use quick_xml::{ + Reader, + Writer, + events::Event, +}; +use std::{ + collections::HashSet, + io::BufRead, +}; + +/// Parse entries independently; retained program metadata, rather than the XML document, +/// determines memory use. The optional time applies the server's normal retention window. +pub(crate) fn read( + input: impl BufRead, + now: Option, +) -> Result { + let mut reader = Reader::from_reader(input); + reader.config_mut().enable_all_checks(true); + let mut buffer = Vec::new(); + let mut entry: Option>> = None; + let mut root_header = Vec::new(); + let mut depth = 0; + let mut root_seen = false; + let mut root_closed = false; + let mut declaration_seen = false; + let mut doctype_seen = false; + let mut guide = Guide { + programs: Vec::new(), + stations: Vec::new(), + }; + loop { + buffer.clear(); + let event = reader + .read_event_into(&mut buffer) + .map_err(|_| "The guide is not valid XMLTV")?; + match &event { + Event::Eof => { + if !root_closed || depth != 0 { + return Err("The guide is incomplete or has no XMLTV tv root".into()); + } + break; + } + Event::Decl(_) => { + if root_seen || declaration_seen || doctype_seen { + return Err("The guide is not valid XMLTV".into()); + } + declaration_seen = true; + } + Event::DocType(value) => { + // Conventional external XMLTV DTDs are accepted but never fetched. Custom + // entities are forbidden even when they occur in an otherwise ignored entry. + if root_seen || doctype_seen || value.contains(" { + if depth == 0 { + if root_seen || start.name().as_ref() != "tv" { + return Err("The guide must contain one XMLTV tv root".into()); + } + // Also validate the root's attributes without retaining the full document. + let mut root = Writer::new(Vec::new()); + root.write_event(Event::Empty(start.clone())) + .map_err(|_| "Invalid XMLTV root")?; + validate(&root.into_inner())?; + let mut root = Writer::new(Vec::new()); + root.write_event(Event::Start(start.clone())) + .map_err(|_| "Invalid XMLTV root")?; + root_header = root.into_inner(); + root_seen = true; + root_closed = matches!(event, Event::Empty(_)); + } else if depth == 1 { + // Preserve root namespace declarations for namespaced guide extensions. + entry = Some(Writer::new(root_header.clone())); + } + if let Some(writer) = &mut entry { + writer + .write_event(event.borrow()) + .map_err(|_| "Unable to read XMLTV entry")?; + } + if matches!(event, Event::Start(_)) { + depth += 1; + } else if depth == 1 { + finish_entry(entry.take().unwrap(), &mut guide, now)?; + } + } + Event::End(_) => { + if depth == 0 { + return Err("The guide is not valid XMLTV".into()); + } + if let Some(writer) = &mut entry { + writer + .write_event(event.borrow()) + .map_err(|_| "Unable to read XMLTV entry")?; + } + depth -= 1; + if depth == 1 { + finish_entry(entry.take().unwrap(), &mut guide, now)?; + } else if depth == 0 { + root_closed = true; + } + } + _ => { + if let Some(writer) = &mut entry { + writer + .write_event(event.borrow()) + .map_err(|_| "Unable to read XMLTV entry")?; + } else { + match &event { + Event::Text(text) => { + let text: &str = text; + if depth == 0 && !text.trim().is_empty() { + return Err("The guide is not valid XMLTV".into()); + } + } + Event::GeneralRef(_) | Event::CData(_) if depth == 0 => { + return Err("The guide is not valid XMLTV".into()); + } + Event::GeneralRef(value) => { + let name: &str = value; + quick_xml::escape::unescape(&format!("&{name};")) + .map_err(|_| "XMLTV custom entities are not supported")?; + } + _ => {} + } + } + } + } + } + let rows = &mut guide.programs; + rows.sort_by(|a, b| (&a.guide_key, a.starts_at).cmp(&(&b.guide_key, b.starts_at))); + // XMLTV stop is optional. Infer it only from the next event on the same station, + // including a boundary event outside the retained schedule. + for index in 0..rows.len().saturating_sub(1) { + if rows[index].ends_at == 0 && rows[index].guide_key == rows[index + 1].guide_key { + rows[index].ends_at = rows[index + 1].starts_at; + } + } + rows.retain(|p| { + p.ends_at > p.starts_at + && now.is_none_or(|time| crate::live_tv::retention::contains(p, time)) + }); + rows.dedup_by(|a, b| a.guide_key == b.guide_key && a.starts_at == b.starts_at); + // Minimal guides may omit channel declarations. Their station IDs remain mappable. + let mut keys = guide + .stations + .iter() + .map(|s| s.guide_key.clone()) + .collect::>(); + for p in rows { + if keys.insert(p.guide_key.clone()) { + guide.stations.push(Station { + source_id: 0, + guide_key: p.guide_key.clone(), + name: p.guide_key.clone(), + number: String::new(), + aliases: Vec::new(), + logo: None, + }); + } + } + Ok(guide) +} + +fn validate(xml: &[u8]) -> Result, String> { + let xml = std::str::from_utf8(xml).map_err(|_| "The guide must use UTF-8 text")?; + roxmltree::Document::parse(xml).map_err(|_| "The guide is not valid XMLTV".into()) +} + +fn finish_entry( + mut writer: Writer>, + guide: &mut Guide, + now: Option, +) -> Result<(), String> { + writer + .write_event(Event::End(quick_xml::events::BytesEnd::new("tv"))) + .map_err(|_| "Unable to read XMLTV entry")?; + let xml = writer.into_inner(); + let doc = validate(&xml)?; + let node = doc + .root_element() + .first_element_child() + .ok_or("Invalid XMLTV entry")?; + match node.tag_name().name() { + "channel" => { + if let Some(station) = station(node) { + guide.stations.push(station); + } + } + "programme" => { + if let Some(mut program) = program(node) { + // Keep only lightweight boundary events outside the window, so missing stop + // times can still be inferred when the feed is unordered. + if now.is_some_and(|time| !crate::live_tv::retention::contains(&program, time)) + && program.ends_at != 0 + { + program.metadata = ProgramMetadata::default(); + program.title.clear(); + } + guide.programs.push(program); + } + } + _ => {} + } + Ok(()) +} + +fn station(node: roxmltree::Node<'_, '_>) -> Option { + let key = node.attribute("id")?; + let aliases = node + .children() + .filter(|n| n.has_tag_name("display-name")) + .filter_map(|n| n.text().map(str::to_owned)) + .collect::>(); + let number = child_text(node, "lcn") + .or_else(|| { + aliases + .iter() + .find(|s| { + s.chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-') + }) + .cloned() + }) + .unwrap_or_default(); + Some(Station { + source_id: 0, + guide_key: key.into(), + name: aliases.first().cloned().unwrap_or_else(|| key.into()), + number, + aliases, + logo: node + .children() + .find(|n| n.has_tag_name("icon")) + .and_then(|n| n.attribute("src")) + .filter(|url| http_url(url).is_ok()) + .map(str::to_owned), + }) +} + +fn program(node: roxmltree::Node<'_, '_>) -> Option { + let (Some(key), Some(start)) = ( + node.attribute("channel"), + node.attribute("start").and_then(xml_time), + ) else { + return None; + }; + let end = node.attribute("stop").and_then(xml_time); + let title = child_text(node, "title")?; + let mut metadata = ProgramMetadata { + subtitle: child_text(node, "sub-title"), + description: child_text(node, "desc"), + ..Default::default() + }; + for child in node.children() { + match child.tag_name().name() { + "category" => { + if let Some(text) = child.text() { + metadata.categories.push(text.to_owned()); + } + } + "icon" => { + metadata.image = child + .attribute("src") + .filter(|s| http_url(s).is_ok()) + .map(str::to_owned) + } + "rating" => metadata.rating = child_text(child, "value"), + "date" => { + metadata.year = child + .text() + .and_then(|value| value.get(..4)) + .and_then(|year| year.parse().ok()) + } + "episode-num" => { + let episode = child.text().map(str::to_owned); + if child.attribute("system") == Some("onscreen") { + metadata.episode = episode; + } else if metadata.episode_id.is_none() { + metadata.episode_id = episode.map(|id| { + format!("{}:{id}", child.attribute("system").unwrap_or("unknown")) + }); + } + if metadata.episode.is_none() && child.attribute("system") == Some("xmltv_ns") { + let parts = child + .text() + .unwrap_or_default() + .split('.') + .take(2) + .map(|part| { + part.split('/') + .next() + .unwrap_or_default() + .trim() + .parse::() + .ok() + .and_then(|number| number.checked_add(1)) + }) + .collect::>(); + if let [Some(season), Some(episode)] = parts.as_slice() { + metadata.episode = Some(format!("S{season}E{episode}")); + } + } + } + "new" => metadata.is_new = Some(true), + "premiere" => { + metadata.is_premiere = Some(true); + metadata.is_new = Some(true); + } + "live" => metadata.is_live = Some(true), + "finale" => metadata.is_finale = Some(true), + "previously-shown" => metadata.is_new = Some(false), + "credits" => { + metadata.credits = child + .children() + .filter_map(|c| c.text()) + .map(str::to_owned) + .collect() + } + _ => {} + } + } + Some(Program { + id: 0, + source_id: 0, + guide_key: key.into(), + starts_at: start, + ends_at: end.unwrap_or(0), + title, + metadata, + }) +} diff --git a/crates/server/src/live_tv/iptv.rs b/crates/server/src/live_tv/iptv.rs new file mode 100644 index 00000000..0097021a --- /dev/null +++ b/crates/server/src/live_tv/iptv.rs @@ -0,0 +1,344 @@ +//! One IPTV connection can import complementary FastChannels transports with stable channel IDs. +use super::{ + Channel, + feeds, + formats, + providers::{ + ChannelIdentity, + Metadata, + }, + sources::SourceConfig, +}; +use std::collections::HashMap; + +pub(super) struct Imported { + pub channels: Vec, + pub identities: HashMap, + pub playlist_bytes: u64, + pub guide: Option<(formats::Guide, u64)>, +} + +pub(super) fn provider(identity: &str) -> String { + identity + .split_once('.') + .map(|(prefix, _)| prefix.trim()) + .filter(|p| !p.is_empty()) + .unwrap_or("other") + .to_ascii_lowercase() +} + +pub(super) fn endpoint( + base: &str, + path: &str, +) -> Result { + let url = formats::http_url(base)?; + if url.query().is_some() { + return Err( + "Enter the FastChannels server address without a playlist path or query".into(), + ); + } + formats::endpoint(base, path) +} + +pub(super) async fn import(config: &SourceConfig) -> Result { + let fast = config.iptv_mode == "fastchannels"; + let urls = if fast { + vec![ + (endpoint(&config.url, "feeds/default/native/m3u")?, false), + (endpoint(&config.url, "feeds/default/m3u/prismcast")?, true), + ( + endpoint(&config.url, "feeds/default/m3u/prismcast/gracenote")?, + true, + ), + ] + } else { + vec![(formats::http_url(&config.url)?, false)] + }; + let mut channels = Vec::new(); + let mut identities = HashMap::new(); + let mut playlist_bytes = 0; + for (url, bridge) in urls { + let (playlist, size) = feeds::playlist_details(url, fast, fast).await?; + playlist_bytes += size; + for channel in playlist.channels { + if identities.contains_key(&channel.source_key) { + continue; + } + let identity = playlist + .identities + .get(&channel.source_key) + .map(String::as_str) + .unwrap_or(""); + identities.insert( + channel.source_key.clone(), + ChannelIdentity { + provider: provider(identity), + bridge, + identity: identity.into(), + }, + ); + channels.push(channel); + } + } + if channels.is_empty() { + return Err("No playable IPTV channels were found".into()); + } + // Native XMLTV includes both direct and DRM-bridge channels, including Gracenote-mapped ones. + let guide = if fast && matches!(config.guide_kind.as_str(), "" | "automatic") { + Some(feeds::guide_input(endpoint(&config.url, "feeds/default/native/epg.xml")?).await?) + } else { + None + }; + Ok(Imported { + channels, + identities, + playlist_bytes, + guide, + }) +} + +pub(super) fn merge_metadata( + metadata: &mut Metadata, + identities: HashMap, +) { + metadata.channels = identities; + // Provider preferences remain even if a provider temporarily disappears from the upstream feed. +} + +/// Converting an existing playlist to the server preset preserves unambiguous channel identities. +pub(super) fn reconcile( + conn: &mut diesel::SqliteConnection, + id: i32, + rows: &[Channel], + incoming: &HashMap, + previous: &Metadata, +) -> diesel::QueryResult<()> { + use crate::db::schema::live_tv_channels as c; + use diesel::prelude::*; + let existing = c::table + .filter(c::source_id.eq(id)) + .select(Channel::as_select()) + .load::(conn)?; + let mut keys = existing + .iter() + .map(|c| c.source_key.clone()) + .collect::>(); + let mut claimed = std::collections::HashSet::new(); + let mut identities: HashMap<&str, Vec<&Channel>> = HashMap::new(); + let mut guide_numbers: HashMap<(&str, &str), Vec<&Channel>> = HashMap::new(); + for channel in &existing { + if let Some(info) = previous + .channels + .get(&channel.source_key) + .filter(|i| !i.identity.is_empty()) + { + identities.entry(&info.identity).or_default().push(channel); + } + guide_numbers + .entry((&channel.guide_key, &channel.number)) + .or_default() + .push(channel); + } + for row in rows { + if keys.contains(&row.source_key) { + continue; + } + let identity = incoming + .get(&row.source_key) + .map(|i| i.identity.as_str()) + .unwrap_or(""); + let matching = identities + .get(identity) + .or_else(|| guide_numbers.get(&(row.guide_key.as_str(), row.number.as_str()))); + if let Some(matching) = matching.filter(|matches| matches.len() == 1) { + let channel = matching[0]; + if claimed.insert(channel.id) { + diesel::update(c::table.find(channel.id)) + .set(c::source_key.eq(&row.source_key)) + .execute(conn)?; + keys.insert(row.source_key.clone()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[rocket::async_test] + async fn one_address_imports_native_and_both_prismcast_variants_with_their_guide() { + use std::io::{ + Read, + Write, + }; + for bridge_status in [200, 409, 503] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let native="#EXTM3U\n#EXTINF:-1 channel-id=\"pluto.news\" \ + tvg-id=\"pluto.news\",News\nhttp://tv.example/news.ts\n"; + let bridge="#EXTM3U\n#EXTINF:-1 channel-id=\"espn.espn1\" \ + tvg-id=\"espn.espn1\",ESPN\nhttp://bridge.example/espn.m3u8\n"; + let gracenote="#EXTM3U\n#EXTINF:-1 channel-id=\"sling.sports\" \ + tvc-guide-stationid=\"123\",Sports\nhttp://bridge.example/sports.m3u8\n"; + let now = chrono::Utc::now(); + let guide = format!( + "ESPNEvent", + now.format("%Y%m%d%H%M%S"), + (now + chrono::Duration::hours(1)).format("%Y%m%d%H%M%S") + ); + let mut responses = vec![ + ("/feeds/default/native/m3u", 200, native.into()), + ("/feeds/default/m3u/prismcast", bridge_status, bridge.into()), + ]; + if bridge_status != 503 { + responses.push(( + "/feeds/default/m3u/prismcast/gracenote", + bridge_status, + gracenote.into(), + )); + responses.push(("/feeds/default/native/epg.xml", 200, guide)); + } + let server = std::thread::spawn(move || { + for (path, status, body) in responses { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut buffer = [0; 4096]; + let size = socket.read(&mut buffer).unwrap(); + assert!( + String::from_utf8_lossy(&buffer[..size]) + .starts_with(&format!("GET {path} ")) + ); + write!( + socket, + "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: \ + close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + let config = SourceConfig { + url: base, + iptv_mode: "fastchannels".into(), + guide_kind: "automatic".into(), + provider_grouping: true, + ..Default::default() + }; + let result = import(&config).await; + server.join().unwrap(); + if bridge_status == 503 { + assert!(result.is_err()); + continue; + } + let result = result.unwrap(); + assert!(result.playlist_bytes > 0); + assert_eq!(result.guide.as_ref().unwrap().0.programs[0].title, "Event"); + assert_eq!( + result.channels.len(), + if bridge_status == 409 { 1 } else { 3 } + ); + if bridge_status == 200 { + assert!(result.identities["fastchannels:espn.espn1"].bridge); + assert_eq!( + result + .channels + .iter() + .find(|c| c.name == "Sports") + .unwrap() + .guide_key, + "sling.sports" + ); + } + } + } + #[test] + fn provider_prefix_is_independent_of_guide_ids_and_channel_names() { + assert_eq!(provider("espn.espn1"), "espn"); + assert_eq!(provider("foxone.MyNetwork TV"), "foxone"); + assert_eq!(provider("124636"), "other"); + assert_eq!( + endpoint("http://localhost:5523/sub/", "feeds/default/native/m3u") + .unwrap() + .path(), + "/sub/feeds/default/native/m3u" + ); + assert!(endpoint("http://localhost:5523?secret=x", "feeds/default/native/m3u").is_err()); + } + + #[test] + fn fastchannels_ids_stay_stable_across_guide_mapping_number_and_transport_changes() { + let mut keys = Vec::new(); + for (guide, number, url) in [ + ("32645", "5167", "direct.ts"), + ("espn.espn1", "90", "bridge.m3u8"), + ] { + let m3u = format!("#EXTM3U\n#EXTINF:-1 channel-id=\"espn.espn1\" \ + tvg-id=\"{guide}\" tvg-chno=\"{number}\",ESPN\nhttp://example.com/{url}\n"); + let parsed = + formats::read_playlist(m3u.as_bytes(), "http://example.com", true, true).unwrap(); + assert_eq!(parsed.channels[0].guide_key, guide); + assert_eq!( + parsed.identities[&parsed.channels[0].source_key], + "espn.espn1" + ); + keys.push(parsed.channels[0].source_key.clone()); + } + assert_eq!(keys[0], keys[1]); + let eplus="#EXTM3U\n#EXTINF:0 channel-id=\"foxone.FOX\" \ + tvg-id=\"410.eplustv\" tvg-chno=\"410\",FOX\nhttp://example.com/fox.ts\n"; + let parsed = + formats::read_playlist(eplus.as_bytes(), "http://example.com", false, false).unwrap(); + assert_eq!(parsed.channels[0].source_key, "410.eplustv:410"); + assert_eq!( + provider(&parsed.identities[&parsed.channels[0].source_key]), + "foxone" + ); + assert!( + formats::read_playlist("#EXTM3U\n".as_bytes(), "http://example.com", true, true) + .unwrap() + .channels + .is_empty() + ); + assert!( + formats::read_playlist("#EXTM3U\n".as_bytes(), "http://example.com", false, false) + .is_err() + ); + } + + #[test] + fn converting_existing_playlist_preserves_channel_ids_and_user_overrides() { + use crate::db::schema::live_tv_channels as c; + use diesel::prelude::*; + let mut conn = crate::live_tv::tests::database(); + let before = c::table + .filter(c::source_id.eq(1)) + .select(Channel::as_select()) + .first::(&mut conn) + .unwrap(); + diesel::update(c::table.find(before.id)) + .set((c::enabled.eq(false), c::guide_override.eq("custom"))) + .execute(&mut conn) + .unwrap(); + let mut next = before.clone(); + next.source_key = "fastchannels:espn.espn1".into(); + reconcile( + &mut conn, + 1, + &[next.clone()], + &HashMap::new(), + &Metadata::default(), + ) + .unwrap(); + super::super::storage::save_channels(&mut conn, 1, &[next]).unwrap(); + let after = super::super::storage::channel(&mut conn, before.id).unwrap(); + assert_eq!(after.source_key, "fastchannels:espn.espn1"); + assert!(!after.enabled); + assert_eq!(after.guide_override.as_deref(), Some("custom")); + } +} diff --git a/crates/server/src/live_tv/mod.rs b/crates/server/src/live_tv/mod.rs index f8f93939..a675f006 100644 --- a/crates/server/src/live_tv/mod.rs +++ b/crates/server/src/live_tv/mod.rs @@ -5,13 +5,18 @@ mod broadcast; mod buffer_files; pub(crate) mod discovery; mod dvr; +pub(crate) mod feeds; mod formats; pub(crate) mod gracenote; +pub(crate) mod iptv; mod native; pub(crate) mod preferences; +pub(crate) mod providers; mod proxy; +pub(crate) mod removal; mod retention; pub(crate) mod settings; +pub(crate) mod setup; mod sources; mod storage; mod stream; @@ -43,6 +48,10 @@ pub(crate) use dvr::{ ScheduleRequest, schedule, }; +pub(crate) use sources::{ + SourceConnection, + connection, +}; pub(crate) use sources::{ nextpvr_artwork, nextpvr_channel_artwork, @@ -96,11 +105,15 @@ pub(crate) async fn remove_recording_file( pub(crate) async fn import_guide( db: &crate::db::DbConn, id: i32, - value: String, + file: std::fs::File, ) -> Result<(), String> { let lock = sources::source_lock(id); let _guard = lock.lock().await; - let guide = formats::parse_guide(&value)?; + let guide_bytes = file + .metadata() + .map_err(|_| "Unable to read the XMLTV file size")? + .len(); + let guide = feeds::read_guide(file).await?; let source = db .run(move |conn| storage::source(conn, id)) .await @@ -112,6 +125,9 @@ pub(crate) async fn import_guide( db.run(move |conn| { storage::source(conn, id)?; storage::save_guide(conn, id, &guide)?; + let mut sizes = setup::feed_sizes(conn, id)?; + sizes.guide_bytes = Some(guide_bytes); + setup::save_feed_sizes(conn, id, &sizes)?; use crate::db::schema::live_tv_sources as s; use diesel::prelude::*; diesel::update(s::table.find(id)) diff --git a/crates/server/src/live_tv/providers.rs b/crates/server/src/live_tv/providers.rs new file mode 100644 index 00000000..54e66cc9 --- /dev/null +++ b/crates/server/src/live_tv/providers.rs @@ -0,0 +1,391 @@ +//! IPTV provider identities, shared visibility and appearance, kept separate from channel/guide +//! IDs. +use super::{ + Channel, + Source, + setup, + sources, + storage, +}; +use crate::db::schema::{ + app_settings as a, + live_tv_channels as c, +}; +use base64::{ + Engine, + engine::general_purpose::STANDARD, +}; +use diesel::prelude::*; +use rocket_okapi::okapi::schemars::{ + self, + JsonSchema, +}; +use serde::{ + Deserialize, + Serialize, +}; +use std::collections::{ + BTreeMap, + HashMap, +}; + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub(super) struct ChannelIdentity { + pub provider: String, + pub bridge: bool, + pub identity: String, +} +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub(super) struct Appearance { + pub name: Option, + pub hidden: bool, + pub image: Option, + pub icon_channel_id: Option, +} +#[derive(Default, Deserialize, Serialize)] +#[serde(default)] +pub(super) struct Metadata { + pub channels: HashMap, + pub providers: BTreeMap, + pub image: Option, +} +impl Metadata { + pub(super) fn image_url( + &self, + id: i32, + provider: Option<&str>, + ) -> Option { + let version = if let Some(provider) = provider { + self.providers.get(provider)?.image.as_ref()? + } else { + self.image.as_ref()? + }; + let mut url = format!("/api/v1/live-tv/sources/{id}/image?v={version}"); + if let Some(provider) = provider { + let mut params = reqwest::Url::parse("http://localhost").unwrap(); + params.query_pairs_mut().append_pair("provider", provider); + url.push('&'); + url.push_str(params.query().unwrap()); + } + Some(url) + } +} +pub(super) fn load( + conn: &mut SqliteConnection, + id: i32, +) -> QueryResult { + setup::load(conn, &format!("live_tv_metadata:{id}")) +} +pub(super) fn save( + conn: &mut SqliteConnection, + id: i32, + data: &Metadata, +) -> QueryResult<()> { + setup::save(conn, &format!("live_tv_metadata:{id}"), data) +} + +#[derive(Serialize, JsonSchema)] +pub(crate) struct ProviderSummary { + pub source_id: i32, + pub id: String, + pub name: String, + pub enabled: bool, + pub channel_count: usize, + pub image_url: Option, + pub icon_channel_id: Option, + pub has_custom_icon: bool, +} +#[derive(Serialize, JsonSchema)] +pub(crate) struct PublicChannel { + #[serde(flatten)] + pub channel: Channel, + pub provider_id: Option, +} + +pub(crate) fn friendly_name(id: &str) -> String { + let name = match id { + "adultswim" => "Adult Swim", + "amazon" | "prime" | "amazon_prime_free" => "Amazon Prime Free Channels", + "bally" | "ballysports" => "Bally Sports Live", + "cbs" | "cbs_sports" => "CBS Sports", + "espn" => "ESPN", + "foxone" => "FOX One", + "lg" | "lg-channels" => "LG Channels", + "localnow" => "Local Now", + "freelivesports" => "Free Live Sports", + "nbc" => "NBC", + "nfl" => "NFL", + "plex" => "Plex", + "pluto" => "Pluto TV", + "roku" => "The Roku Channel", + "samsung" => "Samsung TV Plus", + "sling" => "Sling", + "reelz_tve" => "REELZ", + "stirr" => "STIRR", + "tcl" => "TCL TV+", + "tubi" => "Tubi", + "vizio" => "Vizio WatchFree+", + "whale" => "Whale TV+", + "wsn" => "Women's Sports Network", + "xumo" => "Xumo Play", + "other" => "Other channels", + _ => { + return id + .split(['_', '-']) + .filter(|s| !s.is_empty()) + .map(|s| { + let mut chars = s.chars(); + chars + .next() + .map(|c| c.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + }) + .collect::>() + .join(" "); + } + }; + name.into() +} +pub(super) fn grouped(source: &Source) -> bool { + source.kind == "m3u" && sources::config(source).is_ok_and(|c| c.provider_grouping) +} +pub(super) fn allowed( + source: &Source, + channel: &Channel, + data: &Metadata, +) -> bool { + !grouped(source) + || data + .channels + .get(&channel.source_key) + .and_then(|c| data.providers.get(&c.provider)) + .is_none_or(|p| !p.hidden) +} +pub(crate) fn decorate( + conn: &mut SqliteConnection, + rows: Vec, + admin: bool, +) -> QueryResult> { + let sources = storage::sources(conn)? + .into_iter() + .map(|s| (s.id, s)) + .collect::>(); + let metadata = sources + .keys() + .map(|id| Ok((*id, load(conn, *id)?))) + .collect::>>()?; + Ok(rows + .into_iter() + .filter_map(|channel| { + let source = sources.get(&channel.source_id)?; + let data = metadata.get(&source.id)?; + if !admin && !allowed(source, &channel, data) { + return None; + } + let provider_id = grouped(source) + .then(|| { + data.channels + .get(&channel.source_key) + .map(|c| c.provider.clone()) + }) + .flatten(); + Some(PublicChannel { + channel, + provider_id, + }) + }) + .collect()) +} +pub(super) fn summaries( + conn: &mut SqliteConnection, + source: &Source, + data: &Metadata, +) -> QueryResult> { + if !grouped(source) { + return Ok(vec![]); + } + let channels = c::table + .filter(c::source_id.eq(source.id)) + .filter(c::available.eq(true)) + .select(Channel::as_select()) + .load::(conn)?; + let mut counts = BTreeMap::new(); + for channel in &channels { + if let Some(info) = data.channels.get(&channel.source_key) { + *counts.entry(info.provider.clone()).or_insert(0) += 1; + } + } + Ok(counts + .into_iter() + .map(|(id, count)| { + let settings = data.providers.get(&id).cloned().unwrap_or_default(); + let selected = settings + .icon_channel_id + .and_then(|id| channels.iter().find(|c| c.id == id)) + .and_then(|c| c.logo.clone()); + ProviderSummary { + source_id: source.id, + name: settings.name.unwrap_or_else(|| friendly_name(&id)), + enabled: !settings.hidden, + channel_count: count, + image_url: selected.or_else(|| data.image_url(source.id, Some(&id))), + icon_channel_id: settings.icon_channel_id, + has_custom_icon: settings.image.is_some(), + id, + } + }) + .collect()) +} +pub(crate) fn all_summaries(conn: &mut SqliteConnection) -> QueryResult> { + let mut result = Vec::new(); + for source in storage::sources(conn)?.into_iter().filter(|s| s.enabled) { + let data = load(conn, source.id)?; + result.extend(summaries(conn, &source, &data)?); + } + Ok(result) +} +#[derive(Deserialize, JsonSchema)] +pub(crate) struct ProviderUpdate { + pub name: String, + pub enabled: bool, + pub icon_channel_id: Option, + #[serde(default)] + pub remove_icon: bool, +} +pub(crate) fn update( + conn: &mut SqliteConnection, + id: i32, + provider: &str, + input: ProviderUpdate, +) -> Result<(), String> { + let _source = storage::source(conn, id).map_err(super::db_error)?; + let data = load(conn, id).map_err(super::db_error)?; + if !data.channels.values().any(|c| c.provider == provider) { + return Err("Provider not found".into()); + } + let name = input.name.trim(); + if name.is_empty() || name.len() > 200 { + return Err("Enter a provider name up to 200 characters".into()); + } + if let Some(channel) = input.icon_channel_id { + let channel = storage::channel(conn, channel).map_err(super::db_error)?; + let existing = + data.providers.get(provider).and_then(|p| p.icon_channel_id) == Some(channel.id); + if channel.source_id != id + || (!existing + && data + .channels + .get(&channel.source_key) + .is_none_or(|c| c.provider != provider)) + { + return Err("Choose an icon from this provider's channels".into()); + } + } + conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| { + // Merge into the latest metadata under the write lock; a refresh may have completed + // since validation, or another provider's icon/name may have changed. + let mut data = load(conn, id)?; + let settings = data.providers.entry(provider.into()).or_default(); + settings.name = Some(name.into()); + settings.hidden = !input.enabled; + settings.icon_channel_id = input.icon_channel_id; + if input.remove_icon || input.icon_channel_id.is_some() { + settings.image = None; + } + if input.remove_icon || input.icon_channel_id.is_some() { + diesel::delete(a::table.find(image_key(id, Some(provider)))).execute(conn)?; + } + save(conn, id, &data) + }) + .map_err(super::db_error) +} + +pub(crate) fn prepare_image(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > 5 * 1024 * 1024 { + return Err("Choose an image smaller than 5 MiB".into()); + } + let format = + image::guess_format(bytes).map_err(|_| "Choose a PNG, JPEG, WebP, or GIF image")?; + if !matches!( + format, + image::ImageFormat::Png + | image::ImageFormat::Jpeg + | image::ImageFormat::WebP + | image::ImageFormat::Gif + ) { + return Err("Choose a PNG, JPEG, WebP, or GIF image".into()); + } + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader + .decode() + .map_err(|_| "Unable to read this image")? + .thumbnail(256, 256); + let mut output = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|_| "Unable to save this image")?; + Ok(STANDARD.encode(output.into_inner())) +} +fn image_key( + id: i32, + provider: Option<&str>, +) -> String { + format!("live_tv_image:{id}:{}", provider.unwrap_or("")) +} +pub(crate) fn set_image( + conn: &mut SqliteConnection, + id: i32, + provider: Option<&str>, + image: Option, +) -> Result<(), String> { + storage::source(conn, id).map_err(super::db_error)?; + conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| { + let mut data = load(conn, id)?; + let version = image + .as_ref() + .map(|_| format!("{:016x}", rand::random::())); + if let Some(provider) = provider { + if !data.channels.values().any(|c| c.provider == provider) { + return Err(diesel::result::Error::NotFound); + } + let settings = data.providers.entry(provider.into()).or_default(); + settings.image = version; + settings.icon_channel_id = None; + } else { + data.image = version; + } + let key = image_key(id, provider); + if let Some(image) = image { + setup::save(conn, &key, image)?; + } else { + diesel::delete(a::table.find(key)).execute(conn)?; + } + save(conn, id, &data) + }) + .map_err(super::db_error) +} +pub(crate) fn image( + conn: &mut SqliteConnection, + id: i32, + provider: Option<&str>, +) -> Result, String> { + storage::source(conn, id).map_err(super::db_error)?; + let data = load(conn, id).map_err(super::db_error)?; + if data.image_url(id, provider).is_none() { + return Err("Image not found".into()); + } + let encoded: String = setup::load(conn, &image_key(id, provider)).map_err(super::db_error)?; + STANDARD + .decode(encoded) + .map_err(|_| "Image not found".into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/live_tv/providers/tests.rs b/crates/server/src/live_tv/providers/tests.rs new file mode 100644 index 00000000..f37c5634 --- /dev/null +++ b/crates/server/src/live_tv/providers/tests.rs @@ -0,0 +1,175 @@ +//! Provider preferences and image links must survive refreshes without changing channel identity. +use super::*; +use crate::db::schema::live_tv_sources as s; + +fn fixture() -> (SqliteConnection, Source, Channel, Metadata) { + let mut conn = crate::live_tv::tests::database(); + let config = sources::SourceConfig { + provider_grouping: true, + url: "http://fixture/tv.m3u".into(), + ..Default::default() + }; + diesel::update(s::table.find(1)) + .set(s::config_json.eq(serde_json::to_string(&config).unwrap())) + .execute(&mut conn) + .unwrap(); + let channel = c::table + .filter(c::source_id.eq(1)) + .select(Channel::as_select()) + .first::(&mut conn) + .unwrap(); + let mut data = Metadata::default(); + data.channels.insert( + channel.source_key.clone(), + ChannelIdentity { + provider: "espn".into(), + identity: "espn.espn1".into(), + bridge: true, + }, + ); + save(&mut conn, 1, &data).unwrap(); + let source = storage::source(&mut conn, 1).unwrap(); + (conn, source, channel, data) +} + +#[test] +fn provider_visibility_and_names_preserve_channel_settings_across_refresh() { + let (mut conn, source, channel, data) = fixture(); + update( + &mut conn, + 1, + "espn", + ProviderUpdate { + name: "My Sports".into(), + enabled: false, + icon_channel_id: None, + remove_icon: false, + }, + ) + .unwrap(); + assert!(!allowed(&source, &channel, &load(&mut conn, 1).unwrap())); + assert!(storage::channel(&mut conn, channel.id).unwrap().enabled); + assert!( + decorate(&mut conn, vec![channel.clone()], false) + .unwrap() + .is_empty() + ); + assert_eq!( + decorate(&mut conn, vec![channel.clone()], true).unwrap()[0] + .provider_id + .as_deref(), + Some("espn") + ); + let mut saved = load(&mut conn, 1).unwrap(); + super::super::iptv::merge_metadata(&mut saved, data.channels); + save(&mut conn, 1, &saved).unwrap(); + assert_eq!( + summaries(&mut conn, &source, &saved).unwrap()[0].name, + "My Sports" + ); + update( + &mut conn, + 1, + "espn", + ProviderUpdate { + name: "ESPN".into(), + enabled: true, + icon_channel_id: None, + remove_icon: false, + }, + ) + .unwrap(); + assert!(allowed(&source, &channel, &load(&mut conn, 1).unwrap())); + assert_eq!( + storage::channel(&mut conn, channel.id).unwrap().source_key, + channel.source_key + ); +} + +#[test] +fn channel_linked_provider_images_follow_logo_updates_and_reject_other_sources() { + let (mut conn, source, channel, _) = fixture(); + let foreign = c::table + .filter(c::source_id.eq(2)) + .select(c::id) + .first::(&mut conn) + .unwrap(); + assert!( + update( + &mut conn, + 1, + "espn", + ProviderUpdate { + name: "ESPN".into(), + enabled: true, + icon_channel_id: Some(foreign), + remove_icon: false + } + ) + .is_err() + ); + update( + &mut conn, + 1, + "espn", + ProviderUpdate { + name: "ESPN".into(), + enabled: true, + icon_channel_id: Some(channel.id), + remove_icon: false, + }, + ) + .unwrap(); + for logo in [ + "http://logos.example/first.png", + "http://logos.example/updated.png", + ] { + diesel::update(c::table.find(channel.id)) + .set(c::logo.eq(logo)) + .execute(&mut conn) + .unwrap(); + let data = load(&mut conn, 1).unwrap(); + let provider = summaries(&mut conn, &source, &data).unwrap().remove(0); + assert_eq!(provider.image_url.as_deref(), Some(logo)); + assert_eq!(provider.icon_channel_id, Some(channel.id)); + } + set_image(&mut conn, 1, Some("espn"), Some("fixture".into())).unwrap(); + let data = load(&mut conn, 1).unwrap(); + let provider = summaries(&mut conn, &source, &data).unwrap().remove(0); + assert!(provider.has_custom_icon); + assert_eq!(provider.icon_channel_id, None); + assert!( + provider + .image_url + .unwrap() + .starts_with("/api/v1/live-tv/sources/1/image?") + ); + set_image(&mut conn, 1, Some("espn"), None).unwrap(); + assert!( + load(&mut conn, 1).unwrap().providers["espn"] + .image + .is_none() + ); +} + +#[test] +fn uploaded_icons_are_normalized_bounded_and_replace_previous_images() { + let (mut conn, _, _, _) = fixture(); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1000, 500) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let encoded = prepare_image(png.get_ref()).unwrap(); + let resized = image::load_from_memory(&STANDARD.decode(&encoded).unwrap()).unwrap(); + assert_eq!((resized.width(), resized.height()), (256, 128)); + assert!(prepare_image(b"").is_err()); + assert!(prepare_image(&vec![0; 5 * 1024 * 1024 + 1]).is_err()); + set_image(&mut conn, 1, None, Some(encoded.clone())).unwrap(); + let first = load(&mut conn, 1).unwrap().image_url(1, None).unwrap(); + set_image(&mut conn, 1, None, Some(encoded)).unwrap(); + let second = load(&mut conn, 1).unwrap().image_url(1, None).unwrap(); + assert_ne!(first, second); + assert!(image(&mut conn, 1, None).is_ok()); + set_image(&mut conn, 1, None, None).unwrap(); + assert!(image(&mut conn, 1, None).is_err()); +} diff --git a/crates/server/src/live_tv/proxy.rs b/crates/server/src/live_tv/proxy.rs index 1ef7f792..3dbbbf3b 100644 --- a/crates/server/src/live_tv/proxy.rs +++ b/crates/server/src/live_tv/proxy.rs @@ -274,6 +274,8 @@ async fn serve( .and_then(|h| h.to_str().ok()) .unwrap_or("application/octet-stream") .to_owned(); + // Reqwest's size hint shrinks as chunks are consumed. The relay also sends the sniffed prefix. + let content_length = response.content_length(); let mut first = response .chunk() .await @@ -324,7 +326,7 @@ async fn serve( let mut header = format!( "HTTP/1.1 {status} Upstream\r\nContent-Type: {content_type}\r\nConnection: close\r\n" ); - if let Some(length) = response.content_length() { + if let Some(length) = content_length { header.push_str(&format!("Content-Length: {length}\r\n")); } if let Some(range) = response @@ -363,6 +365,67 @@ async fn serve( #[cfg(test)] mod tests { use super::*; + + #[rocket::async_test] + async fn binary_resources_keep_the_full_length_after_playlist_sniffing() { + for (size, head, range) in [ + (4, false, false), + (1024, false, false), + (256 * 1024, false, false), + (1024, false, true), + (1024, true, false), + ] { + let data = vec![0xa5; size]; + let expected = data.clone(); + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/init.mp4?v=1", upstream.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + let (_, requested_range, _) = request(&mut socket).await.unwrap(); + assert_eq!( + requested_range.as_deref(), + range.then_some("bytes=1024-2047") + ); + let status = if range { "206 Partial Content" } else { "200 OK" }; + let content_range = + if range { "Content-Range: bytes 1024-2047/4096\r\n" } else { "" }; + socket + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: video/mp4\r\nContent-Length: \ + {size}\r\n{content_range}Connection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + socket.write_all(&data).await.unwrap(); + }); + let (_proxy, url) = start(&url).await.unwrap(); + let client = reqwest::Client::new(); + let mut builder = if head { client.head(url) } else { client.get(url) }; + if range { + builder = builder.header(reqwest::header::RANGE, "bytes=1024-2047"); + } + let response = builder.send().await.unwrap(); + assert_eq!(response.status().as_u16(), if range { 206 } else { 200 }); + assert_eq!( + response.headers()[reqwest::header::CONTENT_LENGTH], + size.to_string(), + "The relay must include the bytes already read while detecting playlists" + ); + if range { + assert_eq!( + response.headers()[reqwest::header::CONTENT_RANGE], + "bytes 1024-2047/4096" + ); + } + let body = response.bytes().await.unwrap(); + assert_eq!(body.as_ref(), if head { &[][..] } else { &expected }); + server.await.unwrap(); + } + } + #[test] fn hls_rewrites_nested_playlists_keys_maps_and_byte_range_resources() { let state = State { diff --git a/crates/server/src/live_tv/removal.rs b/crates/server/src/live_tv/removal.rs new file mode 100644 index 00000000..8f5ba8b0 --- /dev/null +++ b/crates/server/src/live_tv/removal.rs @@ -0,0 +1,140 @@ +//! Remove connections while retaining the channel references needed by saved DVR history. +use super::{ + sources, + storage, + stream, +}; +use crate::db::{ + DbConn, + schema::{ + app_settings as a, + live_tv_channels as c, + live_tv_programs as p, + live_tv_recordings as r, + live_tv_series_rules as rules, + live_tv_sources as s, + live_tv_stations as stations, + }, +}; +use diesel::prelude::*; + +/// Archived connections retain DVR foreign keys but cannot be configured or used again. +pub(super) const REMOVED: &str = "removed"; + +pub(crate) enum RemoveError { + Busy, + Database(diesel::result::Error), +} +impl From for RemoveError { + fn from(error: diesel::result::Error) -> Self { + Self::Database(error) + } +} + +pub(crate) async fn remove( + db: &DbConn, + id: i32, +) -> Result<(), RemoveError> { + let setup = sources::source_lock(0); + let _setup_guard = setup.lock().await; + let lock = sources::source_lock(id); + let _guard = lock.lock().await; + let _admission = stream::ADMISSIONS.lock().await; + let previous = db.run(move |conn| remove_connection(conn, id)).await?; + if !previous.starts_with('{') { + let _ = crate::secrets::delete_secret(&previous); + } + Ok(()) +} + +fn remove_connection( + conn: &mut SqliteConnection, + id: i32, +) -> Result { + conn.immediate_transaction(|conn| { + let previous = storage::source(conn, id)?; + let channel_ids = c::table.filter(c::source_id.eq(id)).select(c::id); + let recording = r::table + .filter(r::channel_id.eq_any(channel_ids)) + .filter(r::state.eq_any(["recording", "stopping"])) + .count() + .get_result::(conn)?; + if stream::source_in_use(id) || recording > 0 { + return Err(RemoveError::Busy); + } + diesel::delete( + r::table + .filter(r::channel_id.eq_any(channel_ids)) + .filter(r::state.eq("scheduled")) + .filter(r::file_name.is_null()), + ) + .execute(conn)?; + diesel::update( + r::table + .filter(r::channel_id.eq_any(channel_ids)) + .filter(r::state.eq("scheduled")), + ) + .set((r::state.eq("cancelled"), r::last_error.eq(None::))) + .execute(conn)?; + diesel::delete(rules::table.filter(rules::channel_id.eq_any(channel_ids))).execute(conn)?; + diesel::delete(p::table.filter(p::source_id.eq(id))).execute(conn)?; + diesel::delete(stations::table.filter(stations::source_id.eq(id))).execute(conn)?; + diesel::delete( + c::table + .filter(c::source_id.eq(id)) + .filter(c::id.ne_all(r::table.select(r::channel_id))), + ) + .execute(conn)?; + let retained = c::table + .filter(c::source_id.eq(id)) + .count() + .get_result::(conn)?; + if retained == 0 { + diesel::delete(s::table.find(id)).execute(conn)?; + } else { + // Saved recordings use their stored file paths, never these upstream credentials. + diesel::update(c::table.filter(c::source_id.eq(id))) + .set(( + c::enabled.eq(false), + c::available.eq(false), + c::stream_url.eq(""), + c::logo.eq(None::), + c::guide_key.eq(""), + c::guide_override.eq(None::), + )) + .execute(conn)?; + diesel::update(s::table.find(id)) + .set(( + s::kind.eq(REMOVED), + s::enabled.eq(false), + s::config_json.eq("{}"), + s::next_refresh.eq(i64::MAX), + s::last_error.eq(None::), + )) + .execute(conn)?; + } + let mut groups = super::setup::groups(conn)?; + for group in &mut groups { + group.source_ids.retain(|source| *source != id); + } + groups.retain(|group| group.source_ids.len() >= 2); + super::setup::save_groups(conn, groups) + .map_err(|_| diesel::result::Error::RollbackTransaction)?; + let keys = [ + format!("live_tv_metadata:{id}"), + format!("live_tv_feed_sizes:{id}"), + ]; + diesel::delete( + a::table.filter( + a::key.eq_any(keys).or(a::key + .like(format!("live\\_tv\\_image:{id}:%")) + .escape('\\')), + ), + ) + .execute(conn)?; + Ok(previous.config_json) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/live_tv/removal/tests.rs b/crates/server/src/live_tv/removal/tests.rs new file mode 100644 index 00000000..eb071178 --- /dev/null +++ b/crates/server/src/live_tv/removal/tests.rs @@ -0,0 +1,154 @@ +//! Removal preserves playable DVR files and rejects active captures without partial deletion. +use super::*; + +#[test] +fn removal_keeps_saved_video_and_other_sources_but_clears_configuration_and_timers() { + let mut conn = crate::live_tv::tests::database(); + let folder = tempfile::tempdir().unwrap(); + let path = folder.path().join("saved.ts"); + std::fs::write(&path, b"saved video").unwrap(); + for (start, state, file) in [ + (100_i64, "completed", Some(path.to_str().unwrap())), + (300, "scheduled", None), + (500, "scheduled", Some(path.to_str().unwrap())), + ] { + diesel::insert_into(r::table) + .values(( + r::user_id.eq(1), + r::channel_id.eq(1), + r::title.eq("Saved program"), + r::starts_at.eq(start), + r::ends_at.eq(start + 100), + r::padding_before.eq(0), + r::padding_after.eq(0), + r::state.eq(state), + r::file_name.eq(file), + r::metadata_json.eq("{}"), + )) + .execute(&mut conn) + .unwrap(); + } + diesel::insert_into(rules::table) + .values(( + rules::user_id.eq(1), + rules::channel_id.eq(1), + rules::title.eq("Series"), + rules::new_only.eq(false), + rules::padding_before.eq(0), + rules::padding_after.eq(0), + )) + .execute(&mut conn) + .unwrap(); + super::super::setup::save_groups( + &mut conn, + vec![ + super::super::setup::SourceGroup { + id: "combined".into(), + name: "Combined".into(), + source_ids: vec![1, 2], + }, + ], + ) + .unwrap(); + for key in [ + "live_tv_metadata:1", + "live_tv_feed_sizes:1", + "live_tv_image:1:", + "live_tv_image:1:espn", + "live_tv_metadata:2", + "live_tv_image:11:", + ] { + super::super::setup::save(&mut conn, key, "fixture").unwrap(); + } + let guide = super::super::formats::parse_guide(include_str!("../fixtures/guide.xml")).unwrap(); + storage::save_guide(&mut conn, 1, &guide).unwrap(); + assert!(remove_connection(&mut conn, 1).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"saved video"); + assert_eq!(storage::sources(&mut conn).unwrap().len(), 1); + assert!(storage::source(&mut conn, 1).is_err()); + assert_eq!(storage::channels(&mut conn, true).unwrap().len(), 1); + let saved = storage::recordings(&mut conn, 1).unwrap(); + assert_eq!(saved.len(), 2); + assert_eq!(saved[0].state, "cancelled"); + assert_eq!(saved[1].state, "completed"); + assert_eq!( + super::super::settings::capture_path(&mut conn, saved[0].id).unwrap(), + path + ); + assert!(storage::rules(&mut conn, 1).unwrap().is_empty()); + assert!(super::super::setup::groups(&mut conn).unwrap().is_empty()); + assert_eq!(p::table.count().get_result::(&mut conn).unwrap(), 0); + assert_eq!( + stations::table + .count() + .get_result::(&mut conn) + .unwrap(), + 0 + ); + assert_eq!( + a::table + .select(a::key) + .order(a::key) + .load::(&mut conn) + .unwrap(), + vec![ + "live_tv_image:11:", + "live_tv_metadata:2", + "live_tv_source_groups" + ] + ); + assert_eq!( + s::table + .find(1) + .select((s::kind, s::config_json, s::enabled)) + .first::<(String, String, bool)>(&mut conn) + .unwrap(), + (REMOVED.into(), "{}".into(), false) + ); + assert!( + c::table + .find(1) + .select(c::stream_url) + .first::(&mut conn) + .unwrap() + .is_empty() + ); + assert!(matches!( + remove_connection(&mut conn, 1), + Err(RemoveError::Database(diesel::result::Error::NotFound)) + )); + assert!(remove_connection(&mut conn, 2).is_ok()); + assert_eq!( + s::table + .filter(s::id.eq(2)) + .count() + .get_result::(&mut conn) + .unwrap(), + 0 + ); +} + +#[test] +fn active_source_removal_is_rejected_without_changing_its_settings() { + let mut conn = crate::live_tv::tests::database(); + let id = 93001; + diesel::insert_into(s::table) + .values(( + s::id.eq(id), + s::name.eq("Busy"), + s::kind.eq("m3u"), + s::config_json.eq("{}"), + s::capacity.eq(1), + )) + .execute(&mut conn) + .unwrap(); + let source = storage::source(&mut conn, id).unwrap(); + let lease = stream::lease(&source).unwrap(); + assert!(matches!( + remove_connection(&mut conn, id), + Err(RemoveError::Busy) + )); + assert!(storage::source(&mut conn, id).unwrap().enabled); + drop(lease); + assert!(remove_connection(&mut conn, id).is_ok()); +} diff --git a/crates/server/src/live_tv/setup.rs b/crates/server/src/live_tv/setup.rs new file mode 100644 index 00000000..94c0d988 --- /dev/null +++ b/crates/server/src/live_tv/setup.rs @@ -0,0 +1,168 @@ +//! Durable source grouping and last-import feed sizes, without merging tuner resource pools. +use crate::db::schema::{ + app_settings as a, + live_tv_channels as c, + live_tv_sources as s, +}; +use diesel::prelude::*; +use rocket_okapi::okapi::schemars::{ + self, + JsonSchema, +}; +use serde::{ + Deserialize, + Serialize, +}; +use std::collections::{ + HashMap, + HashSet, +}; + +const GROUPS_KEY: &str = "live_tv_source_groups"; + +#[derive(Clone, Deserialize, Serialize, JsonSchema)] +pub(crate) struct SourceGroup { + pub id: String, + pub name: String, + pub source_ids: Vec, +} + +#[derive(Default, Deserialize, Serialize, JsonSchema)] +#[serde(default)] +pub(crate) struct FeedSizes { + pub playlist_bytes: Option, + pub guide_bytes: Option, +} + +#[derive(Serialize, JsonSchema)] +pub(crate) struct SourceSummary { + #[serde(flatten)] + pub source: super::Source, + pub channel_count: i64, + pub image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iptv_mode: Option, + pub providers: Vec, + #[serde(flatten)] + pub sizes: FeedSizes, +} + +pub(super) fn load( + conn: &mut SqliteConnection, + key: &str, +) -> QueryResult { + Ok(a::table + .find(key) + .select(a::value) + .first::(conn) + .optional()? + .and_then(|value| serde_json::from_str(&value).ok()) + .unwrap_or_default()) +} + +pub(super) fn save( + conn: &mut SqliteConnection, + key: &str, + value: impl Serialize, +) -> QueryResult<()> { + let value = + serde_json::to_string(&value).map_err(|_| diesel::result::Error::RollbackTransaction)?; + diesel::insert_into(a::table) + .values(( + a::key.eq(key), + a::value.eq(&value), + a::updated_at.eq(super::now()), + )) + .on_conflict(a::key) + .do_update() + .set((a::value.eq(&value), a::updated_at.eq(super::now()))) + .execute(conn)?; + Ok(()) +} + +pub(crate) fn groups(conn: &mut SqliteConnection) -> QueryResult> { + load(conn, GROUPS_KEY) +} + +pub(crate) fn save_groups( + conn: &mut SqliteConnection, + mut groups: Vec, +) -> Result<(), String> { + let sources = s::table + .filter(s::kind.ne(super::removal::REMOVED)) + .select(s::id) + .load::(conn) + .map_err(super::db_error)? + .into_iter() + .collect::>(); + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + for group in &mut groups { + group.name = group.name.trim().to_owned(); + if group.id.is_empty() + || group.id.len() > 80 + || !group + .id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + || !ids.insert(group.id.clone()) + { + return Err("Source group IDs must be unique".into()); + } + if group.name.is_empty() + || group.name.len() > 200 + || !names.insert(group.name.to_lowercase()) + { + return Err("Enter a unique name for each source group".into()); + } + group.source_ids.sort_unstable(); + group.source_ids.dedup(); + if group.source_ids.len() < 2 || group.source_ids.iter().any(|id| !sources.contains(id)) { + return Err("Choose at least two existing sources for each group".into()); + } + } + save(conn, GROUPS_KEY, groups).map_err(super::db_error) +} + +pub(super) fn feed_sizes( + conn: &mut SqliteConnection, + id: i32, +) -> QueryResult { + load(conn, &format!("live_tv_feed_sizes:{id}")) +} + +pub(super) fn save_feed_sizes( + conn: &mut SqliteConnection, + id: i32, + sizes: &FeedSizes, +) -> QueryResult<()> { + save(conn, &format!("live_tv_feed_sizes:{id}"), sizes) +} + +pub(crate) fn sources(conn: &mut SqliteConnection) -> QueryResult> { + let counts = c::table + .filter(c::available.eq(true)) + .group_by(c::source_id) + .select((c::source_id, diesel::dsl::count_star())) + .load::<(i32, i64)>(conn)? + .into_iter() + .collect::>(); + super::storage::sources(conn)? + .into_iter() + .map(|source| { + let sizes = feed_sizes(conn, source.id)?; + let metadata = super::providers::load(conn, source.id)?; + Ok(SourceSummary { + channel_count: counts.get(&source.id).copied().unwrap_or(0), + image_url: metadata.image_url(source.id, None), + providers: super::providers::summaries(conn, &source, &metadata)?, + iptv_mode: super::sources::iptv_mode(&source), + source, + sizes, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/server/src/live_tv/setup/tests.rs b/crates/server/src/live_tv/setup/tests.rs new file mode 100644 index 00000000..41528516 --- /dev/null +++ b/crates/server/src/live_tv/setup/tests.rs @@ -0,0 +1,99 @@ +//! Source groups and import statistics preserve the underlying channels and tuner capacities. +use super::*; + +#[test] +fn source_groups_are_durable_and_invalid_edits_preserve_the_saved_lineup() { + let mut conn = crate::live_tv::tests::database(); + let before = crate::live_tv::storage::sources(&mut conn) + .unwrap() + .into_iter() + .map(|s| (s.id, s.capacity)) + .collect::>(); + save_groups( + &mut conn, + vec![SourceGroup { + id: "fast".into(), + name: "FastChannels".into(), + source_ids: vec![2, 1, 1], + }], + ) + .unwrap(); + let saved = groups(&mut conn).unwrap(); + assert_eq!(saved[0].source_ids, vec![1, 2]); + for (name, ids) in [ + ("", vec![1, 2]), + ("Invalid", vec![1]), + ("Missing", vec![1, 999]), + ] { + assert!( + save_groups( + &mut conn, + vec![SourceGroup { + id: "fast".into(), + name: name.into(), + source_ids: ids + }] + ) + .is_err() + ); + assert_eq!(groups(&mut conn).unwrap()[0].name, "FastChannels"); + } + let after = crate::live_tv::storage::sources(&mut conn) + .unwrap() + .into_iter() + .map(|s| (s.id, s.capacity)) + .collect::>(); + assert_eq!(before, after); + save_groups(&mut conn, Vec::new()).unwrap(); + assert!(groups(&mut conn).unwrap().is_empty()); + assert_eq!( + crate::live_tv::storage::channels(&mut conn, true) + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn source_cards_count_available_channels_and_keep_last_import_sizes() { + let mut conn = crate::live_tv::tests::database(); + diesel::update(s::table.find(1)).set(s::config_json.eq(serde_json::json!({ + "iptv_mode":"fastchannels", "url":"http://private-server.example", "pin":"private-pin" + }).to_string())).execute(&mut conn).unwrap(); + save_feed_sizes( + &mut conn, + 1, + &FeedSizes { + playlist_bytes: Some(2_000_000), + guide_bytes: Some(150_000_000), + }, + ) + .unwrap(); + let rows = sources(&mut conn).unwrap(); + let source = rows.iter().find(|s| s.source.id == 1).unwrap(); + assert_eq!(source.channel_count, 1); + assert_eq!(source.sizes.guide_bytes, Some(150_000_000)); + assert_eq!(source.iptv_mode.as_deref(), Some("fastchannels")); + let guide = super::super::storage::guide_sources(&mut conn).unwrap(); + assert_eq!(guide[0].iptv_mode.as_deref(), Some("fastchannels")); + assert!(guide[1].iptv_mode.is_none()); + let public_guide = serde_json::to_string(&guide).unwrap(); + assert!(!public_guide.contains("private-server") && !public_guide.contains("private-pin")); + diesel::update(c::table.filter(c::source_id.eq(1))) + .set(c::available.eq(false)) + .execute(&mut conn) + .unwrap(); + assert_eq!( + sources(&mut conn) + .unwrap() + .iter() + .find(|s| s.source.id == 1) + .unwrap() + .channel_count, + 0 + ); + let json = serde_json::to_string(&rows).unwrap(); + assert!(!json.contains("secret-reference")); + assert!(!json.contains("config_json")); + assert!(!json.contains("private-server") && !json.contains("private-pin")); +} diff --git a/crates/server/src/live_tv/sources.rs b/crates/server/src/live_tv/sources.rs index ec565d3d..df239e6e 100644 --- a/crates/server/src/live_tv/sources.rs +++ b/crates/server/src/live_tv/sources.rs @@ -9,6 +9,7 @@ use super::formats::{ use super::{ Channel, Source, + feeds, formats, storage, }; @@ -38,7 +39,15 @@ use std::time::Duration; #[serde(default)] pub(crate) struct SourceConfig { pub url: String, + /// Empty/playlist accepts an M3U URL; fastchannels accepts one server address. + pub iptv_mode: String, + pub provider_grouping: bool, + /// Shared across all bridged providers; zero selects one simultaneous capture. + pub bridge_capacity: u32, pub pin: String, + /// Connection editors can leave the masked PIN unchanged without retrieving it. + #[serde(skip_serializing)] + pub preserve_pin: bool, pub xmltv_url: String, pub guide_kind: String, /// Automatic uses configured movie/TV providers for artwork; guide preserves guide artwork @@ -58,7 +67,7 @@ pub(crate) struct SourceConfig { pub tuning_options: String, } -#[derive(Deserialize, JsonSchema)] +#[derive(Deserialize, Serialize, JsonSchema)] pub(crate) struct SourceInput { pub name: String, /// hdhomerun, m3u, nextpvr, or tvheadend. @@ -67,6 +76,34 @@ pub(crate) struct SourceInput { pub config: SourceConfig, } +#[derive(Serialize, JsonSchema)] +pub(crate) struct SourceConnection { + pub source: SourceInput, + pub has_pin: bool, +} + +pub(crate) async fn connection( + db: &DbConn, + id: i32, +) -> Result { + let source = db + .run(move |conn| storage::source(conn, id)) + .await + .map_err(super::db_error)?; + let mut config = config(&source)?; + let has_pin = !config.pin.is_empty(); + config.pin.clear(); + Ok(SourceConnection { + source: SourceInput { + name: source.name, + kind: source.kind, + capacity: source.capacity, + config, + }, + has_pin, + }) +} + pub(crate) async fn nextpvr_artwork( db: &crate::db::DbConn, id: i32, @@ -169,13 +206,17 @@ async fn nextpvr_image( } pub(super) fn client() -> Result { + client_builder() + .timeout(Duration::from_secs(90)) + .build() + .map_err(|_| "Unable to create Live TV HTTP client".into()) +} + +pub(super) fn client_builder() -> reqwest::ClientBuilder { Client::builder() .user_agent(concat!("Koko/", env!("CARGO_PKG_VERSION"))) .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(90)) .gzip(true) - .build() - .map_err(|_| "Unable to create Live TV HTTP client".into()) } pub(super) async fn bytes( @@ -232,6 +273,17 @@ pub(super) async fn json(request: reqwest::RequestBuilder) -> Result Option { + if source.kind != "m3u" { + return None; + } + config(source) + .ok() + .filter(|config| config.iptv_mode == "fastchannels") + .map(|config| config.iptv_mode) +} + pub(crate) fn config(source: &Source) -> Result { if source.config_json.starts_with('{') { return serde_json::from_str(&source.config_json) @@ -243,6 +295,16 @@ pub(crate) fn config(source: &Source) -> Result { } fn validate(input: &mut SourceInput) -> Result<(), String> { + if !matches!( + input.config.iptv_mode.as_str(), + "" | "playlist" | "fastchannels" + ) || input.config.bridge_capacity > 64 + || (input.config.iptv_mode == "fastchannels" && input.kind != "m3u") + { + return Err( + "Choose an IPTV playlist or FastChannels server and a bridge limit from 1 to 64".into(), + ); + } if !matches!( input.config.artwork_provider.as_str(), "" | "automatic" | "guide" | "tmdb" | "tvdb" @@ -287,6 +349,9 @@ fn validate(input: &mut SourceInput) -> Result<(), String> { input.capacity = 1; } else { formats::http_url(&input.config.url)?; + if input.config.iptv_mode == "fastchannels" { + super::iptv::endpoint(&input.config.url, "feeds/default/native/m3u")?; + } } if !input.config.xmltv_url.is_empty() { formats::http_url(&input.config.xmltv_url)?; @@ -397,12 +462,16 @@ pub(crate) async fn replace_source( let _setup_guard = setup_lock.lock().await; let lock = source_lock(id); let _guard = lock.lock().await; - validate(&mut input)?; - resolve_native_device(db, &mut input, Some(id)).await?; let previous = db .run(move |conn| storage::source(conn, id)) .await .map_err(super::db_error)?; + if input.config.preserve_pin && input.config.pin.is_empty() { + input.config.pin = config(&previous)?.pin; + } + input.config.preserve_pin = false; + validate(&mut input)?; + resolve_native_device(db, &mut input, Some(id)).await?; if input.kind != previous.kind { return Err("Add a new source to use a different tuner type".into()); } @@ -463,7 +532,7 @@ async fn hdhomerun(source: &SourceConfig) -> Result, String> { async fn automatic_guide( source: &Source, config: &SourceConfig, -) -> Result, String> { +) -> Result)>, String> { let parsed = match source.kind.as_str() { "hdhomerun" => { let base = formats::http_url(&config.url)?; @@ -479,20 +548,26 @@ async fn automatic_guide( .ok_or("The HDHomeRun did not supply guide credentials")?; let mut url = formats::http_url("https://api.hdhomerun.com/api/xmltv")?; url.query_pairs_mut().append_pair("DeviceAuth", auth); - formats::parse_guide(&text(url).await?) - } - "nextpvr" => nextpvr::guide(config).await.map(from_programs), - "tvheadend" => { - formats::parse_guide(&text(formats::endpoint(&config.url, "xmltv/channels")?).await?) + xmltv_guide(url).await } + "nextpvr" => nextpvr::guide(config) + .await + .map(|p| (from_programs(p), None)), + "tvheadend" => xmltv_guide(formats::endpoint(&config.url, "xmltv/channels")?).await, _ if !config.xmltv_url.is_empty() => { - formats::parse_guide(&text(formats::http_url(&config.xmltv_url)?).await?) + xmltv_guide(formats::http_url(&config.xmltv_url)?).await } _ => return Ok(None), }?; Ok(Some(parsed)) } +async fn xmltv_guide(url: Url) -> Result<(Guide, Option), String> { + feeds::guide_input(url) + .await + .map(|(guide, size)| (guide, Some(size))) +} + fn from_programs(programs: Vec) -> Guide { let mut keys = std::collections::HashSet::new(); let stations = programs @@ -574,43 +649,82 @@ async fn refresh_inner( source: &Source, ) -> Result<(), String> { let config = config(source)?; + let mut playlist_bytes = None; + let mut imported_identities = None; + let mut imported_guide = None; let rows = match source.kind.as_str() { + "m3u" => { + let imported = super::iptv::import(&config).await?; + playlist_bytes = Some(imported.playlist_bytes); + imported_identities = Some(imported.identities); + imported_guide = imported.guide; + imported.channels + } "hdhomerun" => hdhomerun(&config).await?, "nextpvr" => nextpvr::channels(&config).await?, "dvb" => super::native::channels(&config).await?, "native" => super::tsduck::channels(source, &config).await?, "tvheadend" => { let url = formats::endpoint(&config.url, "playlist/channels.m3u")?; - formats::m3u(&text(url.clone()).await?, url.as_str())? + let (rows, size) = feeds::playlist_input(url).await?; + playlist_bytes = Some(size); + rows + } + _ => { + let (rows, size) = feeds::playlist_input(formats::http_url(&config.url)?).await?; + playlist_bytes = Some(size); + rows } - _ => formats::m3u(&text(formats::http_url(&config.url)?).await?, &config.url)?, }; let id = source.id; - db.run(move |conn| storage::save_channels(conn, id, &rows)) - .await - .map_err(super::db_error)?; + let stable_ids = config.iptv_mode == "fastchannels"; + db.run(move |conn| { + conn.transaction(|conn| { + if let Some(identities) = imported_identities.as_ref().filter(|_| stable_ids) { + let previous = super::providers::load(conn, id)?; + super::iptv::reconcile(conn, id, &rows, identities, &previous)?; + } + storage::save_channels(conn, id, &rows)?; + if let Some(identities) = imported_identities { + let mut metadata = super::providers::load(conn, id)?; + super::iptv::merge_metadata(&mut metadata, identities); + super::providers::save(conn, id, &metadata)?; + } + let mut sizes = super::setup::feed_sizes(conn, id)?; + sizes.playlist_bytes = playlist_bytes; + super::setup::save_feed_sizes(conn, id, &sizes) + }) + }) + .await + .map_err(super::db_error)?; let guide = match config.guide_kind.as_str() { - "gracenote" | "zap2it" => Some(super::gracenote::guide(&config.zap2it).await?), - "xmltv" => Some(formats::parse_guide( - &text(formats::http_url(&config.xmltv_url)?).await?, - )?), + "automatic" | "" if imported_guide.is_some() => { + imported_guide.map(|(guide, size)| (guide, Some(size))) + } + "gracenote" | "zap2it" => Some((super::gracenote::guide(&config.zap2it).await?, None)), + "xmltv" => Some(xmltv_guide(formats::http_url(&config.xmltv_url)?).await?), "upload" => None, - "broadcast" => Some(super::broadcast::collect(db, source, &config).await?), + "broadcast" => Some((super::broadcast::collect(db, source, &config).await?, None)), "automatic" | "" if matches!(source.kind.as_str(), "dvb" | "native") => { - Some(super::broadcast::collect(db, source, &config).await?) + Some((super::broadcast::collect(db, source, &config).await?, None)) } _ => automatic_guide(source, &config).await?, }; let broadcast = config.guide_kind == "broadcast" || (matches!(source.kind.as_str(), "dvb" | "native") && matches!(config.guide_kind.as_str(), "" | "automatic")); - if let Some(guide) = guide { + if let Some((guide, guide_bytes)) = guide { db.run(move |conn| { - if broadcast { - storage::merge_guide(conn, id, guide) - } else { - storage::save_guide(conn, id, &guide) - } + conn.transaction(|conn| { + if broadcast { + storage::merge_guide(conn, id, guide)?; + } else { + storage::save_guide(conn, id, &guide)?; + } + let mut sizes = super::setup::feed_sizes(conn, id)?; + sizes.guide_bytes = guide_bytes; + super::setup::save_feed_sizes(conn, id, &sizes) + }) }) .await .map_err(super::db_error)?; diff --git a/crates/server/src/live_tv/storage.rs b/crates/server/src/live_tv/storage.rs index aafd1b0a..67c25229 100644 --- a/crates/server/src/live_tv/storage.rs +++ b/crates/server/src/live_tv/storage.rs @@ -154,14 +154,30 @@ pub(crate) struct GuideSource { pub id: i32, pub name: String, pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub iptv_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, } pub(crate) fn guide_sources(conn: &mut SqliteConnection) -> QueryResult> { - s::table + let rows = s::table .filter(s::enabled.eq(true)) - .select((s::id, s::name, s::kind)) + .select(Source::as_select()) .order(s::id) - .load(conn) + .load::(conn)?; + rows.into_iter() + .map(|source| { + let id = source.id; + Ok(GuideSource { + id, + iptv_mode: super::sources::iptv_mode(&source), + name: source.name, + kind: source.kind, + image_url: super::providers::load(conn, id)?.image_url(id, None), + }) + }) + .collect() } #[derive(Clone, Queryable, Selectable, Serialize, JsonSchema)] @@ -211,6 +227,7 @@ pub(crate) struct SeriesRule { pub(crate) fn sources(conn: &mut SqliteConnection) -> QueryResult> { s::table + .filter(s::kind.ne(super::removal::REMOVED)) .order(s::name) .select(Source::as_select()) .load(conn) @@ -220,7 +237,11 @@ pub(crate) fn source( conn: &mut SqliteConnection, id: i32, ) -> QueryResult { - s::table.find(id).select(Source::as_select()).first(conn) + s::table + .find(id) + .filter(s::kind.ne(super::removal::REMOVED)) + .select(Source::as_select()) + .first(conn) } pub(crate) fn channel( @@ -235,10 +256,17 @@ pub(crate) fn channels( include_disabled: bool, ) -> QueryResult> { let mut query = c::table - .filter(c::source_id.eq_any(s::table.filter(s::enabled.eq(true)).select(s::id))) + .filter( + c::source_id.eq_any( + s::table + .filter(s::kind.ne(super::removal::REMOVED)) + .select(s::id), + ), + ) .into_boxed(); if !include_disabled { query = query + .filter(c::source_id.eq_any(s::table.filter(s::enabled.eq(true)).select(s::id))) .filter(c::enabled.eq(true)) .filter(c::available.eq(true)); } diff --git a/crates/server/src/live_tv/stream.rs b/crates/server/src/live_tv/stream.rs index 9a19d072..fbd6b155 100644 --- a/crates/server/src/live_tv/stream.rs +++ b/crates/server/src/live_tv/stream.rs @@ -41,16 +41,26 @@ use tokio::{ }, }; -static LEASES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +static LEASES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +/// Serialize capture admission with removal without waiting for long-running guide refreshes. +pub(super) static ADMISSIONS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -pub(super) struct Lease(i32); +pub(super) struct Lease(Vec<(i32, bool)>); +/// Source removal shares the admission lock with new captures and waits for existing leases. +pub(super) fn source_in_use(id: i32) -> bool { + LEASES.lock().map_or(true, |leases| { + leases.get(&(id, false)).copied().unwrap_or(0) > 0 + }) +} impl Drop for Lease { fn drop(&mut self) { if let Ok(mut leases) = LEASES.lock() { - if let Some(count) = leases.get_mut(&self.0) { - *count -= 1; - if *count == 0 { - leases.remove(&self.0); + for key in &self.0 { + if let Some(count) = leases.get_mut(key) { + *count -= 1; + if *count == 0 { + leases.remove(key); + } } } } @@ -58,35 +68,60 @@ impl Drop for Lease { } pub(super) fn lease(source: &Source) -> Result { + reserve(source, false) +} + +fn reserve( + source: &Source, + bridge: bool, +) -> Result { + let mut limits = vec![((source.id, false), source.capacity)]; + if bridge { + limits.push(( + (source.id, true), + sources::config(source)?.bridge_capacity.max(1) as i32, + )); + } let mut leases = LEASES.lock().map_err(|_| "Unable to reserve a tuner")?; - let count = leases.entry(source.id).or_default(); - if *count >= source.capacity { + if limits + .iter() + .any(|(key, limit)| leases.get(key).copied().unwrap_or(0) >= *limit) + { return Err( "All tuners or allowed IPTV streams are busy. Stop another stream or recording and \ try again" .into(), ); } - *count += 1; - Ok(Lease(source.id)) + for (key, _) in &limits { + *leases.entry(*key).or_default() += 1; + } + Ok(Lease(limits.into_iter().map(|(key, _)| key).collect())) } pub(super) async fn input( db: &DbConn, id: i32, ) -> Result<(Source, Channel, Lease), String> { - let (source, channel) = db + let _admission = ADMISSIONS.lock().await; + let (source, channel, bridge, provider_enabled) = db .run(move |conn| { let channel = storage::channel(conn, id)?; let source = storage::source(conn, channel.source_id)?; - Ok::<_, diesel::result::Error>((source, channel)) + let metadata = super::providers::load(conn, source.id)?; + let provider_enabled = super::providers::allowed(&source, &channel, &metadata); + let bridge = metadata + .channels + .get(&channel.source_key) + .is_some_and(|c| c.bridge); + Ok::<_, diesel::result::Error>((source, channel, bridge, provider_enabled)) }) .await .map_err(super::db_error)?; - if !source.enabled || !channel.enabled || !channel.available { + if !source.enabled || !channel.enabled || !channel.available || !provider_enabled { return Err("This channel is disabled or unavailable".into()); } - let lease = match lease(&source) { + let lease = match reserve(&source, bridge) { Ok(lease) => lease, Err(error) => { if !super::broadcast::preempt(source.id) { @@ -95,7 +130,7 @@ pub(super) async fn input( let wait = async { loop { tokio::time::sleep(std::time::Duration::from_millis(50)).await; - if let Ok(lease) = lease(&source) { + if let Ok(lease) = reserve(&source, bridge) { break lease; } } @@ -435,7 +470,11 @@ impl Drop for Pipeline { } impl Pipeline { - fn report_error(&self) { + pub(super) fn new(child: Child) -> Self { + make_pipeline(child, None, None, None) + } + + pub(super) fn report_error(&self) { if let Ok(data) = self.log.lock() { let message = String::from_utf8_lossy(&data); static URL: Lazy = @@ -580,6 +619,33 @@ pub(crate) async fn play_recording( mod tests { use super::*; #[test] + fn all_bridged_providers_share_one_capture_limit_and_release_it_on_disconnect() { + let source = Source { + id: 910001, + name: "IPTV".into(), + kind: "m3u".into(), + config_json: serde_json::json!({"bridge_capacity":1}).to_string(), + enabled: true, + capacity: 3, + next_refresh: 0, + last_error: None, + }; + let first = reserve(&source, true).unwrap(); + assert!(reserve(&source, true).is_err()); + let direct = lease(&source).unwrap(); + drop(first); + let second = reserve(&source, true).unwrap(); + drop(second); + drop(direct); + assert!( + !LEASES + .lock() + .unwrap() + .keys() + .any(|(id, _)| *id == source.id) + ); + } + #[test] fn only_browser_compatible_progressive_video_uses_original_passthrough() { let compatible = serde_json::json!({"streams":[ {"codec_type":"video", "codec_name":"h264", "pix_fmt":"yuv420p", diff --git a/crates/server/src/live_tv/tests.rs b/crates/server/src/live_tv/tests.rs index 6f36c352..c6bc1957 100644 --- a/crates/server/src/live_tv/tests.rs +++ b/crates/server/src/live_tv/tests.rs @@ -510,7 +510,7 @@ fn xmltv_keeps_rich_metadata_and_uses_broadcast_timezone() { assert_eq!(program.metadata.is_new, Some(true)); assert_eq!(guide.programs[1].ends_at, guide.programs[2].starts_at); assert_eq!(guide.stations[0].number, "7.1"); - assert!(formats::xmltv("]>").is_err()); + assert!(formats::parse_guide("]>").is_err()); assert_eq!(formats::xml_time("20261030230000"), Some(program.starts_at)); } diff --git a/crates/server/src/live_tv/timeshift.rs b/crates/server/src/live_tv/timeshift.rs index 911e4f52..c63a8d30 100644 --- a/crates/server/src/live_tv/timeshift.rs +++ b/crates/server/src/live_tv/timeshift.rs @@ -405,14 +405,17 @@ async fn capture_inner( .arg(session.directory.join("buffer.m3u8")) .stdin(Stdio::piped()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::piped()) .kill_on_drop(true); #[cfg(windows)] command.creation_flags(0x08000000); - let mut segmenter = command - .spawn() - .map_err(|_| "Unable to start the TV buffer; check FFmpeg")?; + let mut segmenter = stream::Pipeline::new( + command + .spawn() + .map_err(|_| "Unable to start the TV buffer; check FFmpeg")?, + ); let mut input = segmenter + .child .stdin .take() .ok_or("TV buffer did not open an input")?; @@ -466,10 +469,14 @@ async fn capture_inner( } } if let Some(status) = segmenter + .child .try_wait() .map_err(|_| "Unable to check the TV buffer")? { if !status.success() { + tokio::task::yield_now().await; + pipeline.report_error(); + segmenter.report_error(); failure = Some("TV buffering stopped; check the source and available disk space".into()); } @@ -487,7 +494,7 @@ async fn capture_inner( { pump.abort(); } - let _ = tokio::time::timeout(Duration::from_secs(3), segmenter.wait()).await; + let _ = tokio::time::timeout(Duration::from_secs(3), segmenter.child.wait()).await; refresh_segments(session).await; copy_kept_segments(session).await?; failure.map_or(Ok(()), Err) diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index 7f3f7402..d33b4bc7 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -49,69 +49,85 @@ pub fn favicon() -> (ContentType, &'static [u8]) { #[get("/assets/providers/")] pub fn provider_logo(filename: &str) -> Result<(ContentType, &'static [u8]), Status> { let asset: (ContentType, &'static [u8]) = match filename { - "librivox.png" => ( - ContentType::PNG, - include_bytes!("../../../../../assets/providers/librivox.png"), + "audiosilo.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/audiosilo.svg"), ), "comicvine.svg" => ( ContentType::SVG, include_bytes!("../../../../../assets/providers/comicvine.svg"), ), - "libex-light.png" => ( + "fanart.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/libex-light.png"), + include_bytes!("../../../../../assets/providers/fanart.png"), + ), + "fastchannels.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/fastchannels.svg"), + ), + "hdhomerun.ico" => ( + ContentType::Icon, + include_bytes!("../../../../../assets/providers/hdhomerun.ico"), ), "libex-dark.png" => ( ContentType::PNG, include_bytes!("../../../../../assets/providers/libex-dark.png"), ), - "audiosilo.svg" => ( - ContentType::SVG, - include_bytes!("../../../../../assets/providers/audiosilo.svg"), + "libex-light.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/libex-light.png"), ), - "tmdb.svg" => ( + "librivox.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/librivox.png"), + ), + "local-nfo.svg" => ( ContentType::SVG, - include_bytes!("../../../../../assets/providers/tmdb.svg"), + include_bytes!("../../../../../assets/providers/local-nfo.svg"), ), - "tvdb-light.png" => ( + "lrclib.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/tvdb-light.png"), + include_bytes!("../../../../../assets/providers/lrclib.png"), ), - "tvdb-dark.png" => ( + "musicbrainz.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/musicbrainz.svg"), + ), + "nextpvr.ico" => ( + ContentType::Icon, + include_bytes!("../../../../../assets/providers/nextpvr.ico"), + ), + "open-library.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/tvdb-dark.png"), + include_bytes!("../../../../../assets/providers/open-library.png"), ), "themerr.png" => ( ContentType::PNG, include_bytes!("../../../../../assets/providers/themerr.png"), ), - "trailerdb-light.svg" => ( + "tmdb.svg" => ( ContentType::SVG, - include_bytes!("../../../../../assets/providers/trailerdb-light.svg"), + include_bytes!("../../../../../assets/providers/tmdb.svg"), ), "trailerdb-dark.svg" => ( ContentType::SVG, include_bytes!("../../../../../assets/providers/trailerdb-dark.svg"), ), - "musicbrainz.svg" => ( + "trailerdb-light.svg" => ( ContentType::SVG, - include_bytes!("../../../../../assets/providers/musicbrainz.svg"), + include_bytes!("../../../../../assets/providers/trailerdb-light.svg"), ), - "fanart.png" => ( + "tvdb-dark.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/fanart.png"), + include_bytes!("../../../../../assets/providers/tvdb-dark.png"), ), - "lrclib.png" => ( + "tvdb-light.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/lrclib.png"), + include_bytes!("../../../../../assets/providers/tvdb-light.png"), ), - "open-library.png" => ( + "tvheadend.png" => ( ContentType::PNG, - include_bytes!("../../../../../assets/providers/open-library.png"), - ), - "local-nfo.svg" => ( - ContentType::SVG, - include_bytes!("../../../../../assets/providers/local-nfo.svg"), + include_bytes!("../../../../../assets/providers/tvheadend.png"), ), _ => return Err(Status::NotFound), }; diff --git a/crates/server/src/web/routes/live_tv.rs b/crates/server/src/web/routes/live_tv.rs index 7d72f4bc..43605d99 100644 --- a/crates/server/src/web/routes/live_tv.rs +++ b/crates/server/src/web/routes/live_tv.rs @@ -130,7 +130,7 @@ fn owner(user: &UserGuard) -> Result { pub async fn get_channels( _user: UserGuard, db: DbConn, -) -> Result>, Error> { +) -> Result>, Error> { db.run(|conn| { let mut channels = tv::channels(conn, false)?; let sources = tv::guide_sources(conn)?; @@ -142,7 +142,7 @@ pub async fn get_channels( channel.logo = Some(format!("/api/v1/live-tv/channels/{}/artwork", channel.id)); } } - Ok::<_, diesel::result::Error>(channels) + tv::providers::decorate(conn, channels, false) }) .await .map(Json) @@ -155,11 +155,14 @@ pub async fn get_channels( pub async fn get_setup_channels( _admin: AdminGuard, db: DbConn, -) -> Result>, Error> { - db.run(|conn| tv::channels(conn, true)) - .await - .map(Json) - .map_err(database) +) -> Result>, Error> { + db.run(|conn| { + let channels = tv::channels(conn, true)?; + tv::providers::decorate(conn, channels, true) + }) + .await + .map(Json) + .map_err(database) } /// Program guide and station identities used for channel mapping. @@ -169,6 +172,8 @@ pub struct GuideResponse { stations: Vec, sources: Vec, preferences: tv::preferences::Preferences, + source_groups: Vec, + providers: Vec, } /// Return a bounded UTC guide window for all enabled sources. @@ -224,6 +229,8 @@ pub async fn get_guide( stations: tv::guide_stations(conn)?, sources, preferences: tv::preferences::load(conn, user)?, + source_groups: tv::setup::groups(conn)?, + providers: tv::providers::all_summaries(conn)?, }) }) .await @@ -392,8 +399,43 @@ pub async fn stream_buffer( pub async fn get_sources( _admin: AdminGuard, db: DbConn, -) -> Result>, Error> { - db.run(tv::list_sources).await.map(Json).map_err(database) +) -> Result>, Error> { + db.run(tv::setup::sources).await.map(Json).map_err(database) +} + +/// Read administrator connection fields, retaining a masked PIN on subsequent edits. +#[openapi(tag = "Live TV")] +#[get("/api/v1/live-tv/sources//connection")] +pub async fn get_source_connection( + _admin: AdminGuard, + db: DbConn, + id: i32, +) -> Result, Error> { + tv::connection(&db, id).await.map(Json).map_err(error) +} + +/// Read shared guide source groups without exposing connection credentials. +#[openapi(tag = "Live TV")] +#[get("/api/v1/live-tv/source-groups")] +pub async fn get_source_groups( + _admin: AdminGuard, + db: DbConn, +) -> Result>, Error> { + db.run(tv::setup::groups).await.map(Json).map_err(database) +} + +/// Group independent source connections into shared guide lineups. +#[openapi(tag = "Live TV")] +#[put("/api/v1/live-tv/source-groups", format = "json", data = "")] +pub async fn set_source_groups( + _admin: AdminGuard, + db: DbConn, + body: Json>, +) -> Result { + db.run(move |conn| tv::setup::save_groups(conn, body.into_inner())) + .await + .map_err(error)?; + Ok(Status::NoContent) } /// Source creation result; refresh errors preserve the configured source for correction. @@ -420,6 +462,31 @@ pub async fn add_source( })) } +/// Remove a connection and its future timers while keeping saved DVR recordings and files. +#[openapi(tag = "Live TV")] +#[delete("/api/v1/live-tv/sources/")] +pub async fn remove_source( + _admin: AdminGuard, + db: DbConn, + id: i32, +) -> Result { + tv::removal::remove(&db, id) + .await + .map_err(|failure| match failure { + tv::removal::RemoveError::Database(value) => database(value), + tv::removal::RemoveError::Busy => rocket::response::status::Custom( + Status::Conflict, + Json(TvError { + kind: "live_tv_operation", + message: "Stop playback and active recordings from this source before \ + removing it" + .into(), + }), + ), + })?; + Ok(Status::NoContent) +} + /// Replace connection settings without discarding channel IDs, mappings, or DVR schedules. #[openapi(tag = "Live TV")] #[put( @@ -439,6 +506,84 @@ pub async fn replace_source( Ok(Status::NoContent) } +/// Queue channel and guide refresh without making the browser wait for a tuner scan. +#[openapi(tag = "Live TV")] +#[put( + "/api/v1/live-tv/sources//providers/", + format = "json", + data = "" +)] +pub async fn set_provider( + _admin: AdminGuard, + db: DbConn, + id: i32, + provider: String, + body: Json, +) -> Result { + db.run(move |conn| tv::providers::update(conn, id, &provider, body.into_inner())) + .await + .map_err(error)?; + Ok(Status::NoContent) +} + +/// Upload an image for a source or provider; binary images are decoded and resized before storage. +#[put("/api/v1/live-tv/sources//image?", data = "")] +pub async fn upload_source_image( + _admin: AdminGuard, + db: DbConn, + id: i32, + provider: Option, + data: rocket::Data<'_>, +) -> Result { + use rocket::data::ToByteUnit; + let bytes = data + .open(5.mebibytes()) + .into_bytes() + .await + .map_err(|_| error("Unable to read the image".into()))?; + if !bytes.is_complete() { + return Err(error("Choose an image smaller than 5 MiB".into())); + } + let bytes = bytes.into_inner(); + let image = rocket::tokio::task::spawn_blocking(move || tv::providers::prepare_image(&bytes)) + .await + .map_err(|_| error("Unable to process the image".into()))? + .map_err(error)?; + db.run(move |conn| tv::providers::set_image(conn, id, provider.as_deref(), Some(image))) + .await + .map_err(error)?; + Ok(Status::NoContent) +} + +/// Restore the bundled icon for a source or provider. +#[delete("/api/v1/live-tv/sources//image?")] +pub async fn remove_source_image( + _admin: AdminGuard, + db: DbConn, + id: i32, + provider: Option, +) -> Result { + db.run(move |conn| tv::providers::set_image(conn, id, provider.as_deref(), None)) + .await + .map_err(error)?; + Ok(Status::NoContent) +} + +/// Read normalized source/provider artwork using the same authenticated image flow as channel +/// icons. +#[get("/api/v1/live-tv/sources//image?")] +pub async fn source_image( + _user: UserGuard, + db: DbConn, + id: i32, + provider: Option, +) -> Result<(rocket::http::ContentType, Vec), Status> { + db.run(move |conn| tv::providers::image(conn, id, provider.as_deref())) + .await + .map(|data| (rocket::http::ContentType::PNG, data)) + .map_err(|_| Status::NotFound) +} + /// Queue channel and guide refresh without making the browser wait for a tuner scan. #[openapi(tag = "Live TV")] #[post("/api/v1/live-tv/sources//refresh")] @@ -482,7 +627,13 @@ pub async fn set_source( return Err(error("The stream limit must be between 1 and 64".into())); } let kind = db - .run(move |conn| s::table.find(id).select(s::kind).first::(conn)) + .run(move |conn| { + s::table + .find(id) + .filter(s::kind.ne("removed")) + .select(s::kind) + .first::(conn) + }) .await .map_err(database)?; if matches!(kind.as_str(), "dvb" | "native") { @@ -490,7 +641,7 @@ pub async fn set_source( } let count = db .run(move |conn| { - diesel::update(s::table.find(id)) + diesel::update(s::table.find(id).filter(s::kind.ne("removed"))) .set((s::enabled.eq(body.enabled), s::capacity.eq(body.capacity))) .execute(conn) }) @@ -803,15 +954,15 @@ pub async fn upload_guide( data: rocket::Data<'_>, ) -> Result { use rocket::data::ToByteUnit; - let value = data - .open(64.mebibytes()) - .into_string() + use tokio::io::AsyncWriteExt; + let mut file = tv::feeds::temporary().map_err(error)?; + tokio::io::copy(&mut data.open(u64::MAX.bytes()), &mut file) .await .map_err(|_| error("Unable to read the XMLTV upload".into()))?; - if !value.is_complete() { - return Err(error("The XMLTV upload exceeds 64 MiB".into())); - } - tv::import_guide(&db, id, value.into_inner()) + file.flush() + .await + .map_err(|_| error("Unable to save the XMLTV upload; check temporary disk space".into()))?; + tv::import_guide(&db, id, file.into_std().await) .await .map_err(error)?; Ok(Status::NoContent) diff --git a/crates/server/src/web/routes/mod.rs b/crates/server/src/web/routes/mod.rs index 2b72b850..e58d233f 100644 --- a/crates/server/src/web/routes/mod.rs +++ b/crates/server/src/web/routes/mod.rs @@ -42,10 +42,15 @@ pub fn api_routes() -> Vec { live_tv::discover_sources, live_tv::scan_tuner, live_tv::get_sources, + live_tv::get_source_connection, + live_tv::get_source_groups, + live_tv::set_source_groups, live_tv::add_source, + live_tv::remove_source, live_tv::replace_source, live_tv::refresh_source, live_tv::set_source, + live_tv::set_provider, live_tv::set_channel, live_tv::get_recordings, live_tv::get_dvr_settings, @@ -136,6 +141,9 @@ pub fn spa_routes() -> Vec { live_tv::stream_buffer, live_tv::program_artwork, live_tv::channel_artwork, + live_tv::source_image, + live_tv::upload_source_image, + live_tv::remove_source_image, live_tv::stream_recording, live_tv::upload_guide, media::stream_item, diff --git a/crates/server/tests/test_web/routes/live_tv.rs b/crates/server/tests/test_web/routes/live_tv.rs index c22f98d5..c67630b5 100644 --- a/crates/server/tests/test_web/routes/live_tv.rs +++ b/crates/server/tests/test_web/routes/live_tv.rs @@ -1,5 +1,209 @@ //! Live TV setup permissions and personal DVR ownership must also hold at the HTTP boundary. +#[rocket::async_test] +async fn iptv_provider_visibility_and_icons_enforce_roles_and_media_cookie_boundaries() { + use crate::test_utils::schema::app_settings as a; + let (client, admin) = create_admin_test_client(Some("iptv_provider_images")).await; + let password = format!("fixture-{:032x}", rand::random::()); + let hash = bcrypt::hash(&password, 4).unwrap(); + DbConn::get_one(client.rocket()).await.unwrap().run(move |conn| { + diesel::insert_into(users::table) + .values((users::id.eq(2),users::username.eq("provider-viewer"), + users::password.eq(hash),users::admin.eq(false))) + .execute(conn).unwrap(); + diesel::insert_into(s::table).values((s::id.eq(1),s::name.eq("IPTV"),s::kind.eq("m3u"),s::config_json.eq(json!({"provider_grouping":true,"url":"http://example.com/tv.m3u"}).to_string()),s::capacity.eq(2),s::next_refresh.eq(i64::MAX))).execute(conn).unwrap(); + for id in [1,2] { + diesel::insert_into(c::table).values((c::id.eq(id),c::source_id.eq(1), + c::source_key.eq(format!("channel-{id}")),c::name.eq(format!("Channel {id}")), + c::number.eq(id.to_string()),c::group_name.eq("Sports"), + c::stream_url.eq("http://example.com/stream"), + c::guide_key.eq(format!("guide-{id}")), + c::logo.eq(format!("http://example.com/logo-{id}.png")))) + .execute(conn).unwrap(); + } + diesel::insert_into(a::table).values((a::key.eq("live_tv_metadata:1"),a::value.eq(json!({"channels":{"channel-1":{"provider":"espn","identity":"espn.espn1","bridge":true},"channel-2":{"provider":"sling","identity":"sling.sports","bridge":true}}}).to_string()),a::updated_at.eq(0))).execute(conn).unwrap(); + }).await; + let viewer = login_user(&client, "provider-viewer", &password, Some(Status::Ok)) + .await + .unwrap(); + let body = json!({"name":"My Sports","enabled":false,"icon_channel_id":1,"remove_icon":false}); + let response = client + .put("/api/v1/live-tv/sources/1/providers/espn") + .header(Header::new("Authorization", format!("Bearer {viewer}"))) + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Forbidden); + let response = client + .put("/api/v1/live-tv/sources/1/providers/espn") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .header(ContentType::JSON) + .body(body.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let channels: Value = client + .get("/api/v1/live-tv/channels") + .header(Header::new("Authorization", format!("Bearer {viewer}"))) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(channels.as_array().unwrap().len(), 1); + assert_eq!(channels[0]["provider_id"], "sling"); + assert!(channels[0].get("stream_url").is_none()); + let guide: Value = client + .get("/api/v1/live-tv/guide") + .header(Header::new("Authorization", format!("Bearer {viewer}"))) + .dispatch() + .await + .into_json() + .await + .unwrap(); + let espn = guide["providers"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "espn") + .unwrap(); + assert_eq!(espn["name"], "My Sports"); + assert_eq!(espn["image_url"], "http://example.com/logo-1.png"); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 3) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let response = client + .put("/api/v1/live-tv/sources/1/image") + .cookie(Cookie::new("koko_media_auth", admin.clone())) + .body(png.get_ref().clone()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + let response = client + .put("/api/v1/live-tv/sources/1/image") + .header(Header::new("Authorization", format!("Bearer {viewer}"))) + .body(png.get_ref().clone()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Forbidden); + for path in [ + "/api/v1/live-tv/sources/1/image", + "/api/v1/live-tv/sources/1/image?provider=espn", + ] { + let response = client + .put(path) + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .body(png.get_ref().clone()) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let response = client + .get(path) + .cookie(Cookie::new("koko_media_auth", viewer.clone())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.content_type(), Some(ContentType::PNG)); + let response = client + .get(path) + .cookie(Cookie::new("koko_media_auth", "invalid")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + let response = client + .delete(path) + .cookie(Cookie::new("koko_media_auth", admin.clone())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + let response = client + .delete(path) + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let response = client + .get(path) + .cookie(Cookie::new("koko_media_auth", viewer.clone())) + .dispatch() + .await; + assert_eq!(response.status(), Status::NotFound); + } + let invalid = json!({"name":"ESPN","enabled":true,"icon_channel_id":2,"remove_icon":false}); + let response = client + .put("/api/v1/live-tv/sources/1/providers/espn") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .header(ContentType::JSON) + .body(invalid.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + + let response = client + .delete("/api/v1/live-tv/sources/1") + .header(Header::new("Authorization", format!("Bearer {viewer}"))) + .dispatch() + .await; + assert_eq!(response.status(), Status::Forbidden); + DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(|conn| { + diesel::insert_into(r::table) + .values(( + r::id.eq(991), + r::user_id.eq(1), + r::channel_id.eq(1), + r::title.eq("Active capture"), + r::starts_at.eq(100_i64), + r::ends_at.eq(200_i64), + r::padding_before.eq(0), + r::padding_after.eq(0), + r::state.eq("recording"), + r::metadata_json.eq("{}"), + )) + .execute(conn) + .unwrap(); + }) + .await; + let response = client + .delete("/api/v1/live-tv/sources/1") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await; + assert_eq!(response.status(), Status::Conflict); + DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(|conn| { + diesel::delete(r::table.find(991)).execute(conn).unwrap(); + }) + .await; + for (path, status) in [ + ("/api/v1/live-tv/sources/1", Status::NoContent), + ("/api/v1/live-tv/sources/1", Status::NotFound), + ("/api/v1/live-tv/sources/999", Status::NotFound), + ] { + let response = client + .delete(path) + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await; + assert_eq!(response.status(), status); + } + let sources: Value = client + .get("/api/v1/live-tv/sources") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(sources, json!([])); +} + use crate::test_utils::{ create_admin_test_client, login_user, @@ -24,6 +228,204 @@ use serde_json::{ json, }; +#[rocket::async_test] +async fn live_tv_connection_editing_retains_saved_pin_and_groups_sources_without_merging_them() { + let (client, admin) = create_admin_test_client(Some("live_tv_source_editing")).await; + let pin = format!("{:032x}", rand::random::()); + let original = pin.clone(); + DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(move |conn| { + for id in [1, 2] { + diesel::insert_into(s::table) + .values(( + s::id.eq(id), + s::name.eq(format!("Source {id}")), + s::kind.eq("nextpvr"), + s::config_json.eq(json!({"url":"http://tv.local:8866","pin":pin, + "guide_kind":"automatic","refresh_hours":12}) + .to_string()), + s::capacity.eq(id), + s::enabled.eq(false), + s::next_refresh.eq(i64::MAX), + )) + .execute(conn) + .unwrap(); + } + }) + .await; + let response = client + .get("/api/v1/live-tv/sources/1/connection") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let mut connection: Value = response.into_json().await.unwrap(); + assert_eq!( + connection["source"]["config"]["url"], + "http://tv.local:8866" + ); + assert_eq!(connection["source"]["config"]["refresh_hours"], 12); + assert_eq!(connection["source"]["config"]["pin"], ""); + assert_eq!(connection["has_pin"], true); + connection["source"]["config"]["preserve_pin"] = json!(true); + connection["source"]["config"]["url"] = json!("http://edited.local:8866"); + let response = client + .put("/api/v1/live-tv/sources/1/connection") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .header(ContentType::JSON) + .body(connection["source"].to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let config = DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(|conn| { + s::table + .find(1) + .select(s::config_json) + .first::(conn) + .unwrap() + }) + .await; + let config: Value = serde_json::from_str(&config).unwrap(); + assert_eq!(config["pin"], original); + assert_eq!(config["url"], "http://edited.local:8866"); + let groups = json!([{"id":"combined","name":"FastChannels","source_ids":[1,2]}]); + let response = client + .put("/api/v1/live-tv/source-groups") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .header(ContentType::JSON) + .body(groups.to_string()) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let guide: Value = client + .get("/api/v1/live-tv/guide") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(guide["source_groups"], groups); + let sources: Value = client + .get("/api/v1/live-tv/sources") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .dispatch() + .await + .into_json() + .await + .unwrap(); + assert_eq!(sources.as_array().unwrap().len(), 2); + assert_eq!(sources[0]["capacity"], 1); + assert_eq!(sources[1]["capacity"], 2); + assert!(!sources.to_string().contains(&original)); + assert!(!guide.to_string().contains(&original)); + for path in [ + "/api/v1/live-tv/sources/1/connection", + "/api/v1/live-tv/source-groups", + ] { + assert_eq!( + client + .get(path) + .header(Header::new("Authorization", "Bearer invalid")) + .dispatch() + .await + .status(), + Status::Unauthorized + ); + } +} + +#[rocket::async_test] +async fn live_tv_large_xmltv_uploads_support_gzip_and_preserve_data_on_invalid_input() { + use std::io::Write; + let (client, admin) = create_admin_test_client(Some("live_tv_large_upload")).await; + DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(|conn| { + diesel::insert_into(s::table) + .values(( + s::id.eq(1), + s::name.eq("IPTV"), + s::kind.eq("m3u"), + s::config_json.eq("{}"), + s::capacity.eq(1), + s::next_refresh.eq(i64::MAX), + )) + .execute(conn) + .unwrap(); + }) + .await; + let now = chrono::Utc::now(); + let xml = format!( + "TVLarge \ + upload", + now.format("%Y%m%d%H%M%S"), + (now + chrono::Duration::hours(1)).format("%Y%m%d%H%M%S") + ); + let padding = format!("", "x".repeat(1017)); + let mut body = String::from(""); + for _ in 0..65 * 1024 { + body.push_str(&padding); + } + body.push_str(&xml); + assert!(body.len() > 64 * 1024 * 1024); + let response = client + .post("/api/v1/live-tv/sources/1/xmltv") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .header(ContentType::XML) + .body(body) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + write!(encoder, "{xml}").unwrap(); + let compressed = encoder.finish().unwrap(); + let response = client + .post("/api/v1/live-tv/sources/1/xmltv") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .body(compressed) + .dispatch() + .await; + assert_eq!(response.status(), Status::NoContent); + let response = client + .post("/api/v1/live-tv/sources/1/xmltv") + .header(Header::new("Authorization", format!("Bearer {admin}"))) + .body("") + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + let (count, config) = DbConn::get_one(client.rocket()) + .await + .unwrap() + .run(|conn| { + ( + p::table + .filter(p::source_id.eq(1)) + .count() + .get_result::(conn) + .unwrap(), + s::table + .find(1) + .select(s::config_json) + .first::(conn) + .unwrap(), + ) + }) + .await; + assert_eq!(count, 1); + assert_eq!( + serde_json::from_str::(&config).unwrap()["guide_kind"], + "upload" + ); +} + #[rocket::async_test] async fn live_tv_program_artwork_respects_the_selected_guide() { let (client, admin) = create_admin_test_client(Some("live_tv_artwork_provider")).await; @@ -55,7 +457,10 @@ async fn live_tv_program_artwork_respects_the_selected_guide() { .execute(conn) .unwrap(); let metadata = if artwork { - json!({"image":"https://example.com/episode.jpg","poster":"https://example.com/series.jpg"}) + json!({ + "image":"https://example.com/episode.jpg", + "poster":"https://example.com/series.jpg" + }) } else { json!({}) }; @@ -65,8 +470,8 @@ async fn live_tv_program_artwork_respects_the_selected_guide() { p::source_id.eq(id), p::guide_key.eq("station"), p::title.eq("Last Man Standing"), - p::starts_at.eq(now-30), - p::ends_at.eq(now+3600), + p::starts_at.eq(now - 30), + p::ends_at.eq(now + 3600), p::metadata_json.eq(metadata.to_string()), )) .execute(conn) @@ -227,6 +632,8 @@ async fn live_tv_routes_enforce_setup_roles_and_recording_ownership() { } for path in [ "/api/v1/live-tv/sources", + "/api/v1/live-tv/sources/1/connection", + "/api/v1/live-tv/source-groups", "/api/v1/live-tv/setup/channels", "/api/v1/live-tv/tuners", "/api/v1/live-tv/tuners/regions", diff --git a/docs/LIVE_TV.md b/docs/LIVE_TV.md index 7dea25db..214b915d 100644 --- a/docs/LIVE_TV.md +++ b/docs/LIVE_TV.md @@ -31,6 +31,47 @@ finds nothing, including when the source is on another subnet. For local tuners, discovered device or enter its driver name/path, then select a supported frequency region. When no tuner is attached, **Find local tuners** reports that none were found. +Each source card shows its channel count and the M3U/XMLTV sizes from its last successful +import, when those files apply. Refresh the source to update these figures. + +Use **Remove source** on a source card to remove its channels, guide, future recording +schedules, and series rules. Saved DVR recordings and their files are kept. Stop playback +and active recordings from the source before removing it. +Open **Edit connection settings** to change its saved values. Leave a saved NextPVR PIN +blank to keep it, or enter a replacement PIN. + +### IPTV providers and FastChannels + +For FastChannels, choose **IPTV (M3U)**, then select **FastChannels server** under +**IPTV connection**. Enter only the server address, such as `http://192.168.1.13:5523`. +Koko imports the default feed's native channels, available PrismCast channels, and their guide. +Enable and configure PrismCast in FastChannels to include its bridged channels. +Set **Simultaneous bridge streams** to the number of captures your PrismCast server allows; +this allowance is shared across its providers. + +For an ordinary playlist, select **M3U playlist** and enter its URL. Enable +**Group channels by provider** when the playlist has provider prefixes in its channel IDs, +as FastChannels and E+TV do. After the import, each provider appears under the source card. +Expand a provider to change its name, show or hide it in the guide, or choose **Manage channels** +to edit only that provider's channel visibility and guide mappings. + +In the guide, select the connection and then choose a provider or **All providers**. +Category filters, favorites, and recently played channels still apply within that selection. + +FastChannels presets, HDHomeRun, NextPVR, and Tvheadend use their application icons. +Other connection types use standard symbols, and each provider has a default icon. +Use **Upload icon** on its Settings card +to replace it with a PNG, JPEG, WebP, or GIF image smaller than 5 MiB. For providers, the +**Provider icon** selector also offers icons from that provider's channels. A selected channel +icon updates automatically when the channel logo changes. Choose **Default provider icon** +or **Use default icon** to restore the bundled icon. + +Under **Guide source groups**, choose **Add a source group**, enter a name, and select two +or more sources. They appear together as one lineup in the guide. For example, group a +regular FastChannels playlist with its separate PrismCast playlist. Each member keeps its +own guide settings and simultaneous stream limit. Editing or deleting a group keeps its +member sources, channels, favorites, and recordings. + Set **Simultaneous streams** to the number of tuners or streams your source permits. Each live viewer and active recording uses one stream. Keeping the program you are watching shares that viewer's stream until you change channel. A direct Linux DVB source uses one @@ -48,6 +89,8 @@ description, poster, and recording options above the guide. When you have severa choose one in the source list. Open **Filters** to use categories, channel groups, favorites, recently played channels, or search. Close the filters to leave more room for the guide. Category filters and **NEW**, **LIVE**, **PREMIERE**, and **FINALE** labels depend on your guide's metadata. +With a guide cell focused, use **Page Up / Page Down** to move through a large lineup and +**Home / End** to reach its first or last channel. Up/down movement keeps the same schedule time. Up and down keep the same time position as you move between channels. Left and right move between programs. Completed half-hour columns disappear automatically; choose **Now** to return from a future guide window to the current listings. @@ -80,6 +123,10 @@ You can also choose **Import XMLTV** on an existing source and select a local XM Importing a file selects that uploaded guide until you configure another guide source. Upload a newer file when its listings expire. +M3U playlists and XMLTV guides have no fixed import size or entry-count limit. Large feeds +need enough free temporary disk space and memory to process their channels and listings. +Gzip-compressed XMLTV files are supported for both URLs and uploads. + Koko automatically removes expired listings, keeping up to 24 hours of guide history and 21 days of upcoming programs. This also applies when a source is offline or disabled. Saved recordings and their program details are retained until you delete the recording. @@ -105,7 +152,8 @@ Guide duration and descriptions depend on the broadcaster. Some stations supply current and next program; others supply several days. Broadcast schedules may lack episode numbers, images, or new-episode flags. -Under **Channel visibility and guide mapping**, disable unwanted channels or select the correct +Open **Channel visibility and guide mapping** on the relevant source card. Search or page +through that source's channels, disable unwanted channels, or select the correct guide station when its listings are missing or incorrect. You can watch channels and schedule recordings by time even without guide data. From 6e1221ab8c6205e5bd7f73c36eacf714b507ef49 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:43:11 -0400 Subject: [PATCH 12/49] feat: polish Live TV provider settings and fix IPTV playback Replace provider badges with bundled logos, collapse source and provider settings by default, and use an in-app confirmation for source removal. Handle FOX One provider aliases, inline HLS keys, delayed audio probing, and actionable upstream playback errors. --- assets/providers/README.md | 71 +++++++--- assets/providers/tv-adultswim.png | Bin 0 -> 5144 bytes assets/providers/tv-amazon.svg | 1 + assets/providers/tv-bally.svg | 26 ++++ assets/providers/tv-cbs.png | Bin 0 -> 16230 bytes assets/providers/tv-espn.png | Bin 0 -> 4120 bytes assets/providers/tv-foxone.ico | Bin 0 -> 15086 bytes assets/providers/tv-freelivesports.png | Bin 0 -> 1339 bytes assets/providers/tv-fubo.png | Bin 0 -> 3501 bytes assets/providers/tv-hallmark.png | Bin 0 -> 10217 bytes assets/providers/tv-hulu.png | Bin 0 -> 1357 bytes assets/providers/tv-lg.svg | 15 +++ assets/providers/tv-localnow.png | Bin 0 -> 5802 bytes assets/providers/tv-nfl.png | Bin 0 -> 49024 bytes assets/providers/tv-plex.svg | 1 + assets/providers/tv-pluto.png | Bin 0 -> 10223 bytes assets/providers/tv-reelz.png | Bin 0 -> 11530 bytes assets/providers/tv-roku.svg | 1 + assets/providers/tv-samsung.svg | 4 + assets/providers/tv-sling.svg | 9 ++ assets/providers/tv-spectrum.png | Bin 0 -> 3905 bytes assets/providers/tv-stirr.png | Bin 0 -> 23777 bytes assets/providers/tv-tubi.png | Bin 0 -> 10175 bytes assets/providers/tv-vizio.png | Bin 0 -> 3284 bytes assets/providers/tv-whale.svg | 17 +++ assets/providers/tv-wsn.png | Bin 0 -> 30093 bytes assets/providers/tv-xumo.svg | 19 +++ assets/providers/tv-youtube.png | Bin 0 -> 2965 bytes .../client-web/src/app/confirmationDialog.css | 9 ++ .../client-web/src/app/confirmationDialog.ts | 36 +++++ crates/client-web/src/app/input.ts | 4 +- crates/client-web/src/app/liveTv.css | 13 +- crates/client-web/src/app/liveTvActions.ts | 1 + crates/client-web/src/app/liveTvIcons.ts | 14 +- crates/client-web/src/app/liveTvProviders.ts | 9 +- crates/client-web/src/app/liveTvSetup.ts | 15 ++- .../client-web/src/app/liveTvSourceActions.ts | 18 ++- .../tests/e2e/iptv-providers.spec.mjs | 42 ++++-- .../tests/e2e/live-tv-inline-keys.spec.mjs | 56 ++++++++ .../tests/e2e/live-tv-source-errors.spec.mjs | 26 ++++ crates/client-web/tests/e2e/live-tv.spec.mjs | 4 + crates/client-web/tests/live-tv.test.mjs | 1 + crates/client-web/tests/navigation.test.mjs | 2 +- crates/server/src/live_tv/iptv.rs | 1 + crates/server/src/live_tv/providers.rs | 2 +- crates/server/src/live_tv/proxy.rs | 126 ++++++++++++++++-- crates/server/src/live_tv/stream.rs | 89 ++++++++++++- crates/server/src/live_tv/timeshift.rs | 13 +- crates/server/src/web/routes/common.rs | 108 +++++++++++++++ docs/LIVE_TV.md | 3 +- 50 files changed, 683 insertions(+), 73 deletions(-) create mode 100644 assets/providers/tv-adultswim.png create mode 100644 assets/providers/tv-amazon.svg create mode 100644 assets/providers/tv-bally.svg create mode 100644 assets/providers/tv-cbs.png create mode 100644 assets/providers/tv-espn.png create mode 100644 assets/providers/tv-foxone.ico create mode 100644 assets/providers/tv-freelivesports.png create mode 100644 assets/providers/tv-fubo.png create mode 100644 assets/providers/tv-hallmark.png create mode 100644 assets/providers/tv-hulu.png create mode 100644 assets/providers/tv-lg.svg create mode 100644 assets/providers/tv-localnow.png create mode 100644 assets/providers/tv-nfl.png create mode 100644 assets/providers/tv-plex.svg create mode 100644 assets/providers/tv-pluto.png create mode 100644 assets/providers/tv-reelz.png create mode 100644 assets/providers/tv-roku.svg create mode 100644 assets/providers/tv-samsung.svg create mode 100644 assets/providers/tv-sling.svg create mode 100644 assets/providers/tv-spectrum.png create mode 100644 assets/providers/tv-stirr.png create mode 100644 assets/providers/tv-tubi.png create mode 100644 assets/providers/tv-vizio.png create mode 100644 assets/providers/tv-whale.svg create mode 100644 assets/providers/tv-wsn.png create mode 100644 assets/providers/tv-xumo.svg create mode 100644 assets/providers/tv-youtube.png create mode 100644 crates/client-web/src/app/confirmationDialog.css create mode 100644 crates/client-web/src/app/confirmationDialog.ts create mode 100644 crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs create mode 100644 crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs diff --git a/assets/providers/README.md b/assets/providers/README.md index a9e0b7f0..9b1bfa09 100644 --- a/assets/providers/README.md +++ b/assets/providers/README.md @@ -4,27 +4,54 @@ The backend embeds these files and serves them at `/assets/providers/` Provider descriptors supply those URLs to clients; no logo is downloaded at runtime. Vite serves the same files for development and mock previews. -| Asset | Source | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `audiosilo.svg` | Unmodified [AudioSilo Meta site logo](https://github.com/kodestar/audiosilo-meta/blob/main/site/public/logo.svg) | -| `comicvine.svg` | Inline Comic Vine logo extracted from the [official API page](https://comicvine.gamespot.com/api/), with the SVG namespace added for standalone use | -| `fanart.png` | Unmodified [Fanart.tv wiki logo](https://github.com/fanart-tv/fanartwiki/blob/master/docs/assets/images/fatv-logo.png) | -| `fastchannels.svg` | Unmodified [FastChannels application icon](https://github.com/kineticman/FastChannels/blob/main/app/static/favicon.svg) | -| `hdhomerun.ico` | Unmodified [HDHomeRun site icon](https://info.hdhomerun.com/info/_media/favicon.ico) | -| `libex-dark.png` | Unmodified [Libex dark-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo-dark.png) | -| `libex-light.png` | Unmodified [Libex light-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo.png) | -| `librivox.png` | Unmodified [LibriVox site logo](https://librivox.org/wp-content/themes/librivox/images/librivox-logo.png) | -| `local-nfo.svg` | Original document icon for Koko's local sidecar metadata provider | -| `lrclib.png` | Unmodified [LRCLIB site logo](https://lrclib.net/assets/lrclib-370c57eb.png) | -| `musicbrainz.svg` | MusicBrainz symbol extracted without the wordmark from the [official header logo](https://static.metabrainz.org/MB/header-logo-1f7dc2a.svg); [official logo sources](https://musicbrainz.org/doc/About/Logos) | -| `nextpvr.ico` | Unmodified [NextPVR site icon](https://nextpvr.com/favicon.ico) | -| `open-library.png` | Unmodified [Open Library site icon](https://openlibrary.org/static/images/openlibrary-192x192.png) | -| `themerr.png` | Unmodified [Themerr icon](https://github.com/LizardByte/Themerr/blob/master/web/images/icon-default.png) | -| `tmdb.svg` | Unmodified [TMDB Primary short (blue) SVG](https://www.themoviedb.org/assets/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg); [attribution guidelines](https://www.themoviedb.org/about/logos-attribution) | -| `trailerdb-dark.svg` | TrailerDB favicon with its stroke changed to `#e6edf6` for dark backgrounds | -| `trailerdb-light.svg` | Unmodified [TrailerDB favicon](https://trailerdb.org/favicon.svg) | -| `tvdb-dark.png` | Unmodified [TheTVDB dark-background attribution logo](https://thetvdb.com/images/attribution/logo1.png) | -| `tvdb-light.png` | Unmodified [TheTVDB light-background attribution logo](https://thetvdb.com/images/attribution/logo2.png) | -| `tvheadend.png` | Unmodified [Tvheadend application icon](https://github.com/tvheadend/tvheadend/blob/master/src/webui/static/img/logo.png) | +| Asset | Source | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `audiosilo.svg` | Unmodified [AudioSilo Meta site logo](https://github.com/kodestar/audiosilo-meta/blob/main/site/public/logo.svg) | +| `comicvine.svg` | Inline Comic Vine logo extracted from the [official API page](https://comicvine.gamespot.com/api/), with the SVG namespace added for standalone use | +| `fanart.png` | Unmodified [Fanart.tv wiki logo](https://github.com/fanart-tv/fanartwiki/blob/master/docs/assets/images/fatv-logo.png) | +| `fastchannels.svg` | Unmodified [FastChannels application icon](https://github.com/kineticman/FastChannels/blob/main/app/static/favicon.svg) | +| `hdhomerun.ico` | Unmodified [HDHomeRun site icon](https://info.hdhomerun.com/info/_media/favicon.ico) | +| `libex-dark.png` | Unmodified [Libex dark-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo-dark.png) | +| `libex-light.png` | Unmodified [Libex light-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo.png) | +| `librivox.png` | Unmodified [LibriVox site logo](https://librivox.org/wp-content/themes/librivox/images/librivox-logo.png) | +| `local-nfo.svg` | Original document icon for Koko's local sidecar metadata provider | +| `lrclib.png` | Unmodified [LRCLIB site logo](https://lrclib.net/assets/lrclib-370c57eb.png) | +| `musicbrainz.svg` | MusicBrainz symbol extracted without the wordmark from the [official header logo](https://static.metabrainz.org/MB/header-logo-1f7dc2a.svg); [official logo sources](https://musicbrainz.org/doc/About/Logos) | +| `nextpvr.ico` | Unmodified [NextPVR site icon](https://nextpvr.com/favicon.ico) | +| `open-library.png` | Unmodified [Open Library site icon](https://openlibrary.org/static/images/openlibrary-192x192.png) | +| `themerr.png` | Unmodified [Themerr icon](https://github.com/LizardByte/Themerr/blob/master/web/images/icon-default.png) | +| `tmdb.svg` | Unmodified [TMDB Primary short (blue) SVG](https://www.themoviedb.org/assets/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg); [attribution guidelines](https://www.themoviedb.org/about/logos-attribution) | +| `trailerdb-dark.svg` | TrailerDB favicon with its stroke changed to `#e6edf6` for dark backgrounds | +| `trailerdb-light.svg` | Unmodified [TrailerDB favicon](https://trailerdb.org/favicon.svg) | +| `tv-adultswim.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/adult-swim-us.png) | +| `tv-amazon.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/amazon-prime.svg) | +| `tv-bally.svg` | [Bally Sports Live](https://www.ballysports.com/_next/static/media/bally_logo_red.8f7d15c0.svg) | +| `tv-cbs.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/cbs-sports-us.png) | +| `tv-espn.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/espn-us.png) | +| `tv-foxone.ico` | [FOX One](https://www.foxone.com/favicon.ico) | +| `tv-freelivesports.png` | [Free Live Sports](https://gizmeon.mdc.akamaized.net/PUB-50183/config/1784278232296.png) | +| `tv-fubo.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/fubo-tv.png) | +| `tv-hallmark.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/hallmark-channel-us.png) | +| `tv-hulu.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/hulu.png) | +| `tv-lg.svg` | [LG](https://media.us.lg.com/m/4f3e261da34f4910/original/lg_logo.svg) | +| `tv-localnow.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/local-now-us.png) | +| `tv-nfl.png` | [NFL](https://static.www.nfl.com/league/run3dfyjniqxah4ehxfu) | +| `tv-plex.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/plex-alt-light.svg) | +| `tv-pluto.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/pluto-tv.png) | +| `tv-reelz.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/reelz-us.png) | +| `tv-roku.svg` | [Simple Icons (white wordmark)](https://github.com/simple-icons/simple-icons/blob/develop/icons/roku.svg); white fill for dark backgrounds | +| `tv-samsung.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/samsung-tv-plus.svg) | +| `tv-sling.svg` | [Sling](https://www.sling.com/content/dam/sling-television/icons/logo-sling.svg) | +| `tv-spectrum.png` | [Spectrum TV](https://watch.spectrum.net/assets/18.0.2/images/apple-touch-icon.png?v=2598111e) | +| `tv-stirr.png` | [STIRR](https://vcz-ktest2-cloud-vodlix-com.b-cdn.net/u/ktest2/images/branding/custom/app_icon.png?lu=1792528577) | +| `tv-tubi.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/tubi.png) | +| `tv-vizio.png` | [Vizio WatchFree+](https://www.vizio.com/content/dam/vizio/2025/watchfreeplus/watchfree-logo.png) | +| `tv-whale.svg` | [Whale TV+](https://cdn.prod.website-files.com/6666d37a275951ae96644f4b/66c7b2a5415c4dd4e1ca6379_whaletv%2B_logo_whitepink_RGB.svg) | +| `tv-wsn.png` | [Women's Sports Network](https://womenssports.com/wp-content/uploads/2026/05/WSNLogoFuego.png) | +| `tv-xumo.svg` | [Xumo Play](https://www.xumo.com/static/images/refresh/xumo-play-logo.svg); white wordmark for dark backgrounds | +| `tv-youtube.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/youtube-tv.png) | +| `tvdb-dark.png` | Unmodified [TheTVDB dark-background attribution logo](https://thetvdb.com/images/attribution/logo1.png) | +| `tvdb-light.png` | Unmodified [TheTVDB light-background attribution logo](https://thetvdb.com/images/attribution/logo2.png) | +| `tvheadend.png` | Unmodified [Tvheadend application icon](https://github.com/tvheadend/tvheadend/blob/master/src/webui/static/img/logo.png) | Brand names and logos belong to their respective owners. diff --git a/assets/providers/tv-adultswim.png b/assets/providers/tv-adultswim.png new file mode 100644 index 0000000000000000000000000000000000000000..0def9ecdbe791d1f1e6e8d6465585a8bfe858537 GIT binary patch literal 5144 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 \ No newline at end of file diff --git a/assets/providers/tv-bally.svg b/assets/providers/tv-bally.svg new file mode 100644 index 00000000..454658c1 --- /dev/null +++ b/assets/providers/tv-bally.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/providers/tv-cbs.png b/assets/providers/tv-cbs.png new file mode 100644 index 0000000000000000000000000000000000000000..a585f4d71005594b66cfe120c3d0ad837c60e8eb GIT binary patch literal 16230 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

w*sy1yjM8~v)&Ru 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 Pp8z7mqokL^R5||wbSL}j literal 0 HcmV?d00001 diff --git a/assets/providers/tv-lg.svg b/assets/providers/tv-lg.svg new file mode 100644 index 00000000..3f27a7c2 --- /dev/null +++ b/assets/providers/tv-lg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-localnow.png b/assets/providers/tv-localnow.png new file mode 100644 index 0000000000000000000000000000000000000000..cc6fa70c56a3244130c8cc89b16b2e2f3bb803c7 GIT binary patch literal 5802 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 A*#H0l literal 0 HcmV?d00001 diff --git a/assets/providers/tv-nfl.png b/assets/providers/tv-nfl.png new file mode 100644 index 0000000000000000000000000000000000000000..13010f9a040456002d6776f821631afc00f2f770 GIT binary patch literal 49024 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 literal 0 HcmV?d00001 diff --git a/assets/providers/tv-vizio.png b/assets/providers/tv-vizio.png new file mode 100644 index 0000000000000000000000000000000000000000..be0bb33b062bfa180fbd2fd3963a99bbdb851360 GIT binary patch literal 3284 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 c${w(M-biI1$Cb4mzYGu)L!?2izEjNq03 + + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-wsn.png b/assets/providers/tv-wsn.png new file mode 100644 index 0000000000000000000000000000000000000000..8f1aed843bda0dbd032b130237cf22941ad8f9d3 GIT binary patch literal 30093 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 + + + + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-youtube.png b/assets/providers/tv-youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..5a3c6604c2ae42edeb7964f9b07d78513fd429b4 GIT binary patch literal 2965 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 T^v-Wz`DMYv%*wRD#5L;Q!)qy3 literal 0 HcmV?d00001 diff --git a/crates/client-web/src/app/confirmationDialog.css b/crates/client-web/src/app/confirmationDialog.css new file mode 100644 index 00000000..3109543e --- /dev/null +++ b/crates/client-web/src/app/confirmationDialog.css @@ -0,0 +1,9 @@ +.confirmation-dialog { width:min(32rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);margin:auto;padding:1.5rem;border:1px solid var(--surface-raised);border-radius:var(--radius-card);background:var(--surface-panel);color:inherit;font:inherit;box-shadow:0 1rem 4rem #0008; } +.confirmation-dialog::backdrop { background:#000b;backdrop-filter:blur(3px); } +.confirmation-dialog h2 { margin:0 0 .75rem;font-size:1.35rem; } +.confirmation-dialog p { color:var(--text-muted);line-height:1.6; } +.confirmation-dialog-actions { display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.75rem;margin-top:1.5rem; } +.confirmation-dialog button { padding:.7rem 1rem;border:0;border-radius:.65rem;background:var(--surface-raised);color:inherit;font:inherit;font-weight:600;cursor:pointer; } +.confirmation-dialog button:hover { filter:brightness(1.15); } +.confirmation-dialog button:focus-visible { outline:2px solid var(--accent);outline-offset:3px; } +.confirmation-dialog .confirmation-dialog-confirm { background:#b33743;color:#fff; } diff --git a/crates/client-web/src/app/confirmationDialog.ts b/crates/client-web/src/app/confirmationDialog.ts new file mode 100644 index 00000000..7a568841 --- /dev/null +++ b/crates/client-web/src/app/confirmationDialog.ts @@ -0,0 +1,36 @@ +/** Presents themed confirmation dialogs with native focus trapping and keyboard dismissal. */ +import './confirmationDialog.css'; + +/** Text and destructive-action label for a confirmation presented inside Koko. */ +export interface ConfirmationOptions {title:string;message:string;confirmLabel:string} + +/** Resolve once, restoring focus on dismissal and cancelling if browser navigation leaves the page. */ +export function confirmAction(options:ConfirmationOptions):Promise { + const previous=document.activeElement as HTMLElement|null; + const dialog=document.createElement('dialog');dialog.className='confirmation-dialog'; + dialog.setAttribute('aria-labelledby','confirmation-dialog-title'); + dialog.setAttribute('aria-describedby','confirmation-dialog-message'); + const title=document.createElement('h2');title.id='confirmation-dialog-title';title.textContent=options.title; + const message=document.createElement('p');message.id='confirmation-dialog-message';message.textContent=options.message; + const actions=document.createElement('div');actions.className='confirmation-dialog-actions'; + const cancel=document.createElement('button');cancel.type='button';cancel.textContent='Cancel'; + const confirm=document.createElement('button');confirm.type='button';confirm.className='confirmation-dialog-confirm';confirm.textContent=options.confirmLabel; + actions.append(cancel,confirm);dialog.append(title,message,actions); + return new Promise(resolve=>{ + const events=new AbortController();let finished=false; + const finish=(accepted:boolean)=>{ + if(finished) {return;}finished=true;events.abort();dialog.close();dialog.remove(); + if(previous?.isConnected) {previous.focus();}resolve(accepted); + }; + cancel.addEventListener('click',()=>finish(false),{signal:events.signal}); + confirm.addEventListener('click',()=>finish(true),{signal:events.signal}); + dialog.addEventListener('cancel',event=>{event.preventDefault();finish(false);},{signal:events.signal}); + dialog.addEventListener('close',()=>finish(false),{signal:events.signal}); + dialog.addEventListener('click',event=>{ + const box=dialog.getBoundingClientRect(); + if(event.target===dialog&&(event.clientXbox.right||event.clientYbox.bottom)) finish(false); + },{signal:events.signal}); + globalThis.addEventListener('popstate',()=>finish(false),{signal:events.signal}); + document.body.append(dialog);dialog.showModal();cancel.focus(); + }); +} diff --git a/crates/client-web/src/app/input.ts b/crates/client-web/src/app/input.ts index eef4aeb6..29c6edb8 100644 --- a/crates/client-web/src/app/input.ts +++ b/crates/client-web/src/app/input.ts @@ -362,14 +362,14 @@ function handleNavigationKey(event: KeyboardEvent): void { /** Bind section-aware navigation for browsing pages, leaving settings and players to their own controls. */ export function bindGlobalInputHandlers(state: Pick): void { - const suspended = () => state.isPlayerOpen || Boolean(state.activeTrailer) || state.route.page === 'settings' || document.hidden || Boolean(document.querySelector('dialog.book-reader[open]')); + const suspended = () => state.isPlayerOpen || Boolean(state.activeTrailer) || state.route.page === 'settings' || document.hidden || Boolean(document.querySelector('dialog[open]')); globalThis.addEventListener('pointermove', (event) => { if (event.movementX || event.movementY) setInputMode('pointer'); }, { passive: true }); globalThis.addEventListener('pointerdown', (event) => { setInputMode('pointer'); rememberTvGuidePointer(event); }, { passive: true }); globalThis.addEventListener('focusin', syncNavigationState); globalThis.addEventListener('keydown', (event) => { - if (document.querySelector('dialog.book-reader[open]')) return; + if (document.querySelector('dialog[open]')) return; if (suspended() || event.altKey || event.ctrlKey || event.metaKey) return; if (event.defaultPrevented) { if ((event.target as HTMLElement | null)?.matches('[data-resume-hold]') && ['Enter', ' '].includes(event.key)) setInputMode('keyboard'); diff --git a/crates/client-web/src/app/liveTv.css b/crates/client-web/src/app/liveTv.css index 347fb490..fd62c9c3 100644 --- a/crates/client-web/src/app/liveTv.css +++ b/crates/client-web/src/app/liveTv.css @@ -32,8 +32,15 @@ .live-tv-source .live-tv-channel-mapping label:has(select) { grid-column: 1 / -1; } .live-tv-source input, .live-tv-source select { max-width: 100%; min-width: 0; } .tv-source-heading { display:flex;align-items:center;gap:1rem;flex-wrap:wrap; } -.tv-source-heading h3 { flex:1;min-width:8rem; } -.tv-source-icon { display:inline-grid;position:relative;place-items:center;flex:0 0 3rem;width:3rem;height:3rem;border-radius:.65rem;overflow:hidden;background:var(--surface-raised); } +.tv-source-heading strong { flex:1;min-width:0;font-size:1.1rem;overflow-wrap:anywhere; } +.tv-source-summary { display:flex;align-items:center;justify-content:space-between;gap:1rem;cursor:pointer;list-style:none; } +.tv-source-summary::-webkit-details-marker,.tv-provider-setting > summary::-webkit-details-marker { display:none; } +.tv-source-overview { display:grid;gap:.5rem;min-width:0; } +.tv-source-overview > :not(.tv-source-heading) { color:var(--text-muted);font-size:.9rem;font-weight:400; } +.tv-source-icon { display:inline-grid;position:relative;place-items:center;flex:0 0 3rem;width:3rem;height:3rem; } +.tv-disclosure-icon { display:inline-flex;flex-shrink:0;transition:transform .15s; } +details[open] > summary > .tv-disclosure-icon { transform:rotate(90deg); } +.tv-disclosure-icon svg { width:1.25rem;height:1.25rem; } .tv-source-icon .tv-icon-fallback,.tv-source-icon img { grid-area:1 / 1;width:100%;height:100%; } .tv-icon-fallback { display:grid;place-items:center; } .tv-icon-fallback svg { width:100%;height:100%; } @@ -46,7 +53,7 @@ .tv-icon-upload input[type="file"] { position:absolute;inset:0;opacity:0;cursor:pointer;width:100%; } .tv-provider-setting > summary { display:flex;align-items:center;gap:1rem;flex-wrap:wrap; } .tv-provider-setting > summary > strong { flex:1; } -.tv-provider-setting > summary > span:last-child { color:var(--text-muted);font-weight:400; } +.tv-provider-setting > summary > span:not(.tv-source-icon):not(.tv-disclosure-icon) { color:var(--text-muted);font-weight:400; } .tv-provider-icon-choice { grid-column:1 / -1; } @media (max-width: 700px) { .live-tv-page { padding: .75rem; } diff --git a/crates/client-web/src/app/liveTvActions.ts b/crates/client-web/src/app/liveTvActions.ts index f88ab5f8..56acded5 100644 --- a/crates/client-web/src/app/liveTvActions.ts +++ b/crates/client-web/src/app/liveTvActions.ts @@ -23,6 +23,7 @@ export interface LiveTvState { setupKind?: string; setupAdding?: boolean; regions?: string[]; sourceGroups?:api.TvSourceGroup[]; sourceGroupId?:string; connections?:Record;connectionOpen?:number;connectionLoading?:number; + sourceCardsOpen?:number[]; connectionDrafts?:Record>; mappingSource?:number;mappingQuery?:string;mappingPage?:number; mappingProvider?:string;providerEditor?:string;providerView?:string;providers?:api.TvProvider[]; diff --git a/crates/client-web/src/app/liveTvIcons.ts b/crates/client-web/src/app/liveTvIcons.ts index b9b99011..0723e2a6 100644 --- a/crates/client-web/src/app/liveTvIcons.ts +++ b/crates/client-web/src/app/liveTvIcons.ts @@ -1,12 +1,12 @@ -/** Shared source-type symbols and provider badges, with uploaded/channel-linked image overrides. */ +/** Bundled application and provider logos, with uploaded or channel-linked image overrides. */ import type { TvGuideSource, TvProvider } from '../liveTvApi'; import { resolveApiUrl } from '../api'; import { escapeHtml as escape } from './format'; import { renderIcon } from './ui'; import type { AppIconName } from './types'; -const PROVIDERS:Record={espn:['ESPN','#cf2439'],adultswim:['AS','#252c3c'],sling:['SLING','#0c6d9b'],pluto:['pluto','#5a3cb0'],roku:['roku','#662d91'],tubi:['tubi','#665500'],foxone:['FOX','#125aa8'],bally:['BALLY','#a52936'],wsn:['WSN','#673bb1'],plex:['plex','#806300'],samsung:['TV+','#16348d'],lg:['LG','#a60940'],amazon:['prime','#12658a']}; -const ALIASES:Record={amazon_prime_free:'amazon',ballysports:'bally','lg-channels':'lg'}; +const PROVIDERS:Record={adultswim:'png',amazon:'svg',bally:'svg',cbs:'png',espn:'png',foxone:'ico',freelivesports:'png',fubo:'png',hallmark:'png',hulu:'png',lg:'svg',localnow:'png',nfl:'png',plex:'svg',pluto:'png',reelz:'png',roku:'svg',samsung:'svg',sling:'svg',spectrum:'png',stirr:'png',tubi:'png',vizio:'png',whale:'svg',wsn:'png',xumo:'svg',youtube:'png'}; +const ALIASES:Record={adult_swim:'adultswim',amazon_prime_free:'amazon',ballysports:'bally',cbs_sports:'cbs',charter:'spectrum',fox_one:'foxone',fubotv:'fubo','lg-channels':'lg',pluto_tv:'pluto',prime:'amazon',reelz_tve:'reelz',roku_channel:'roku',samsungtvplus:'samsung',sling_freestream:'sling',slingtv:'sling',watchfree:'vizio',youtube_tv:'youtube',youtubetv:'youtube'}; const SOURCE_NAMES:Record={fastchannels:'FastChannels',hdhomerun:'HDHomeRun',nextpvr:'NextPVR',m3u:'IPTV',tvheadend:'Tvheadend',native:'Local tuner',dvb:'Local tuner'}; const SOURCE_LOGOS:Record={fastchannels:'fastchannels.svg',hdhomerun:'hdhomerun.ico',nextpvr:'nextpvr.ico',tvheadend:'tvheadend.png'}; /** Use authenticated Koko artwork or an ordinary HTTP image, never arbitrary markup or data URLs. */ @@ -36,9 +36,9 @@ export function recoverTvSourceImage(image:HTMLImageElement):void { if(backup){delete image.dataset.tvSourceFallback;image.src=backup;image.hidden=false;} else image.hidden=true; } -/** Bundled provider badges work offline; an uploaded or channel-linked image overrides the badge. */ +/** Prefer custom/channel artwork, then the real provider logo, with Lucide for unknown brands. */ export function renderTvProviderIcon(provider:Pick):string { - const [text,color]=PROVIDERS[ALIASES[provider.id] ?? provider.id] ?? [provider.name.split(/\s+/).map(s=>s[0] ?? '').join('').slice(0,3).toUpperCase(),'#34445b']; - const fallback=``; - return frame(fallback,tvSourceImage(provider.image_url),`${provider.name} provider icon`); + const id=ALIASES[provider.id] ?? provider.id,extension=PROVIDERS[id]; + const logo=extension?resolveApiUrl(`/assets/providers/tv-${id}.${extension}`):'',custom=tvSourceImage(provider.image_url); + return frame(renderIcon('tv','tv-type-symbol'),custom||logo,`${provider.name} provider icon`,custom?logo:''); } diff --git a/crates/client-web/src/app/liveTvProviders.ts b/crates/client-web/src/app/liveTvProviders.ts index 4da022e1..dff7eb79 100644 --- a/crates/client-web/src/app/liveTvProviders.ts +++ b/crates/client-web/src/app/liveTvProviders.ts @@ -3,6 +3,7 @@ import type { TvSource, TvProvider } from '../liveTvApi'; import type { LiveTvState } from './liveTvActions'; import { escapeHtml as escape } from './format'; import { renderTvProviderIcon,renderTvSourceIcon } from './liveTvIcons'; +import { renderIcon } from './ui'; /** Avatar-style image picker shared by connection and provider appearance controls. */ export function tvIconPicker(source:number,name:string,provider?:string):string { @@ -11,8 +12,12 @@ export function tvIconPicker(source:number,name:string,provider?:string):string } /** Source identity and image controls span the card instead of squeezing connection fields. */ export function renderTvSourceHeading(source:TvSource):string { + return `${renderTvSourceIcon(source)}${escape(source.name)}`; +} +/** Keep upload and reset actions outside the source card's disclosure button. */ +export function renderTvSourceAppearance(source:TvSource):string { const reset=source.image_url?'':''; - return `

${renderTvSourceIcon(source)}

${escape(source.name)}

${tvIconPicker(source.id,source.name)}${reset}
`; + return `
${tvIconPicker(source.id,source.name)}${reset}
`; } function editor(source:TvSource,provider:TvProvider,data:LiveTvState):string { const channels=data.channels.filter(c=>c.source_id===source.id&&c.provider_id===provider.id&&c.logo); @@ -35,7 +40,7 @@ export function renderTvProviders(source:TvSource,data:LiveTvState):string { const providers=source.providers ?? [];if(!providers.length) {return '';} const cards=providers.map(p=>{ const key=source.id+':'+p.id,open=data.providerEditor===key,content=open?editor(source,p,data):''; - return `
${renderTvProviderIcon(p)}${escape(p.name)}${p.channel_count} ${p.channel_count===1?'channel':'channels'} · ${p.enabled?'Shown':'Hidden'}${content}
`; + return `
${renderTvProviderIcon(p)}${escape(p.name)}${p.channel_count} ${p.channel_count===1?'channel':'channels'} · ${p.enabled?'Shown':'Hidden'}${renderIcon('chevron-right','tv-disclosure-icon')}${content}
`; }).join(''); return `

Providers

Choose which providers appear in the guide, rename them, and select their icons.

${cards}
`; } diff --git a/crates/client-web/src/app/liveTvSetup.ts b/crates/client-web/src/app/liveTvSetup.ts index bc12a2df..3be3ba6e 100644 --- a/crates/client-web/src/app/liveTvSetup.ts +++ b/crates/client-web/src/app/liveTvSetup.ts @@ -4,7 +4,8 @@ import { escapeHtml as escape, formatFileSize } from './format'; import { state } from './state'; import type { LiveTvState } from './liveTvActions'; import { renderTvSourceChannels,renderTvSourceGroups } from './liveTvSourceLists'; -import { renderTvSourceHeading,renderTvProviders } from './liveTvProviders'; +import { renderTvSourceHeading,renderTvSourceAppearance,renderTvProviders } from './liveTvProviders'; +import { renderIcon } from './ui'; /** Keep Live TV administration inside the Settings workspace. */ export function renderLiveTvSettings(): string { @@ -21,15 +22,17 @@ function renderSource(source: TvSource, data: LiveTvState): string { const open = data.connectionOpen === source.id; let editor = ''; if (open) editor = data.connections?.[source.id] ? renderAddSource(source) : '

Loading saved connection settings…

'; - const sizeMarkup = sizes ? '

Last imported: '+escape(sizes)+'

' : ''; - return `
${renderTvSourceHeading(source)}

${escape(source.kind)} · ${source.enabled ? 'Enabled' : 'Disabled'} · ${count} ${count === 1 ? 'channel' : 'channels'}

${sizeMarkup} + const sizeMarkup = sizes ? 'Last imported: '+escape(sizes)+'' : ''; + const expanded=data.sourceCardsOpen?.includes(source.id) ?? false; + return `
${renderTvSourceHeading(source)}${source.enabled ? 'Enabled' : 'Disabled'} · ${count} ${count === 1 ? 'channel' : 'channels'}${source.last_error?' · Refresh failed':''}${sizeMarkup}${renderIcon('chevron-right','tv-disclosure-icon')} +
${renderTvSourceAppearance(source)} ${source.last_error ? `

${escape(source.last_error)}

` : '

Channels and guide refresh automatically.

'}
${scan}
Edit connection settings

Edit the saved connection and guide settings. Leave a saved PIN blank to keep it.

${editor}
${renderTvProviders(source,data)}${renderTvSourceChannels(source,data)} -
`; +
`; } function renderSetup(data: LiveTvState): string { @@ -60,7 +63,7 @@ function renderConnection(kind: string, savedPin = false): string {

Install v4l-utils and generate this channel file with dvbv5-scan. For automatic channel scanning, choose Local tuner instead.

`; if (kind === 'm3u') return ` - +

Works with FastChannels, E+TV, and other playlists that include provider prefixes. Provider names and icons can be customized after importing.

`; @@ -71,7 +74,7 @@ function renderConnection(kind: string, savedPin = false): string { return `
${escape(label)} connection

- +

Discovery searches from the Koko server. You can also enter the address manually, including a custom port or HTTPS.

${pin}`; } diff --git a/crates/client-web/src/app/liveTvSourceActions.ts b/crates/client-web/src/app/liveTvSourceActions.ts index 54fe501f..e1c75f5a 100644 --- a/crates/client-web/src/app/liveTvSourceActions.ts +++ b/crates/client-web/src/app/liveTvSourceActions.ts @@ -1,6 +1,7 @@ /** Loads saved connection editors and binds source-specific channel pages and guide groups. */ import * as api from '../liveTvApi'; import { state } from './state'; +import { confirmAction } from './confirmationDialog'; type Run = (operation:()=>Promise,button?:HTMLButtonElement)=>Promise; @@ -67,13 +68,24 @@ export function hydrateTvProviders():void { /** Bind durable grouping and bounded source editors without exposing a stored PIN. */ export function bindTvSourceSettings(signal: AbortSignal, context:{render:()=>void}, run:Run): void { hydrateTvProviders(); - document.querySelectorAll('[data-tv-source-remove]').forEach(button=>button.addEventListener('click',()=>{ + document.querySelectorAll('details[data-tv-source-card]').forEach(details=>details.addEventListener('toggle',()=>{ + const data=state.liveTv;if(!data) return; + const id=Number(details.dataset.tvSourceCard),open=new Set(data.sourceCardsOpen ?? []); + if(open.has(id)===details.open) return; + if(details.open) open.add(id);else open.delete(id); + data.sourceCardsOpen=[...open]; + },{signal})); + document.querySelectorAll('[data-tv-source-remove]').forEach(button=>button.addEventListener('click',async()=>{ const id=Number(button.dataset.tvSourceRemove),source=state.liveTv?.sources.find(s=>s.id===id); - if(!source||!globalThis.confirm(`Remove "${source.name}" and its channels? Future recording schedules and series rules for this source will be removed. Saved recordings and their files will be kept.`)) return; + if(!source) return; + const owner=state.liveTv?.owner; + const accepted=await confirmAction({title:`Remove ${source.name}?`,message:'This removes its channels, guide, future recording schedules, and series rules. Saved recordings and their files will be kept.',confirmLabel:'Remove source'}); + if(!accepted||state.liveTv?.owner!==owner||state.route.page!=='settings'||state.route.section!=='live-tv') return; void run(async()=>{ await api.removeTvSource(id); const data=state.liveTv;if(!data) return; delete data.connections?.[id];delete data.connectionDrafts?.[id]; + data.sourceCardsOpen=data.sourceCardsOpen?.filter(source=>source!==id); if(data.connectionOpen===id) data.connectionOpen=undefined; if(data.mappingSource===id) {data.mappingSource=undefined;data.mappingProvider='';} data.providerEditor=undefined; @@ -86,7 +98,7 @@ export function bindTvSourceSettings(signal: AbortSignal, context:{render:()=>vo const form=select.form;if(!form) {return;} const fast=select.value==='fastchannels',label=form.querySelector('[data-tv-iptv-address-label]'),input=form.elements.namedItem('url') as HTMLInputElement; if(label) label.textContent=fast?'FastChannels server address':'IPTV playlist URL'; - input.placeholder=fast?'http://192.168.1.13:5523':'https://provider.example/channels.m3u'; + input.placeholder=fast?'http://fastchannels.example:5523':'https://provider.example/channels.m3u'; form.querySelector('[data-tv-iptv-server-help]')?.toggleAttribute('hidden',!fast); const bridge=form.querySelector('[data-tv-iptv-bridge]');if(bridge){bridge.hidden=!fast;bridge.disabled=!fast;} }; diff --git a/crates/client-web/tests/e2e/iptv-providers.spec.mjs b/crates/client-web/tests/e2e/iptv-providers.spec.mjs index 5dd84254..13d71024 100644 --- a/crates/client-web/tests/e2e/iptv-providers.spec.mjs +++ b/crates/client-web/tests/e2e/iptv-providers.spec.mjs @@ -2,6 +2,11 @@ import {test,expect} from '@playwright/test'; import {createServer} from 'node:http'; +async function openSettingsSource(card) { + if(!(await card.evaluate(element=>element.open))) await card.locator(':scope > summary').click(); + await expect(card).toHaveJSProperty('open',true); +} + test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons current',async({page},testInfo)=>{ test.setTimeout(180000); let revision=1,sourceId,headers; @@ -13,7 +18,7 @@ test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons response.setHeader('Content-Type','application/x-mpegurl'); if(request.url==='/feeds/default/native/m3u') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="pluto.news" tvg-id="pluto.news" tvg-chno="100" tvg-logo="${base}/pluto.png" group-title="News",Pluto News\n${base}/native.ts\n`); else if(request.url==='/feeds/default/m3u/prismcast') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="espn.espn1" tvg-id="espn.espn1" tvg-chno="200" tvg-logo="${base}/espn-${revision}.png" group-title="Sports",ESPN\n${base}/bridge.m3u8\n`); - else if(request.url==='/feeds/default/m3u/prismcast/gracenote') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="sling.sports" tvc-guide-stationid="123" tvg-chno="300" group-title="Sports",Sling Sports\n${base}/other.m3u8\n`); + else if(request.url==='/feeds/default/m3u/prismcast/gracenote') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="sling.sports" tvc-guide-stationid="123" tvg-chno="300" group-title="Sports",Sling Sports\n${base}/other.m3u8\n#EXTINF:-1 channel-id="fox_one.fox_sports" tvg-id="fox_one.fox_sports" tvg-chno="400" group-title="Sports",FOX Sports\n${base}/fox.m3u8\n`); else if(request.url==='/feeds/default/native/epg.xml') { const now=Math.floor(Date.now()/1000);response.setHeader('Content-Type','application/xml'); response.end(''+['pluto.news','espn.espn1','sling.sports'].map(id=>`${id}${id} program`).join('')+''); @@ -27,24 +32,33 @@ test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; await page.goto('/settings/live-tv'); + const addPanel=page.locator('#tv-add-source-panel');if(!(await addPanel.evaluate(element=>element.open))) await addPanel.locator('summary').click(); await page.locator('#tv-source-kind').selectOption('m3u'); const form=page.locator('#tv-source-form');await form.locator('[name="name"]').fill('FastChannels test');await form.locator('[name="iptv_mode"]').selectOption('fastchannels'); await expect(form.locator('[name="provider_grouping"]')).toBeChecked();await expect(form.getByText('FastChannels server address',{exact:true})).toBeVisible(); await form.locator('[name="url"]').fill(base);await form.locator('[name="capacity"]').fill('5');await form.getByRole('button',{name:'Add source',exact:true}).click(); await expect.poll(async()=>{ const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json();const source=sources.find(s=>s.name==='FastChannels test');sourceId=source?.id;return source?.channel_count; - },{timeout:45000}).toBe(3); + },{timeout:45000}).toBe(4); for(const path of ['/feeds/default/native/m3u','/feeds/default/m3u/prismcast','/feeds/default/m3u/prismcast/gracenote','/feeds/default/native/epg.xml'])expect(requested.has(path)).toBeTruthy(); await page.reload(); const card=page.locator(`[data-tv-source-card="${sourceId}"]`); - await expect(card).toContainText('3 channels'); + await expect(card).toHaveJSProperty('open',false); + await expect(card).toContainText('4 channels'); await expect(card.locator('.tv-source-heading img')).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); await page.goto('/live-tv'); const presetIcon=page.locator(`[data-tv-source="${sourceId}"] img`); await expect(presetIcon).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); await expect.poll(()=>presetIcon.evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + const fastFox=page.locator(`[data-tv-provider="${sourceId}:fox_one"]`); + await expect(fastFox).toContainText('FOX One'); + await expect(fastFox.locator('img')).toHaveAttribute('src',/\/assets\/providers\/tv-foxone\.ico$/); + await fastFox.scrollIntoViewIfNeeded(); + await expect.poll(()=>fastFox.locator('img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); await page.goto('/settings/live-tv'); + await openSettingsSource(card); const details=card.locator(`[data-tv-provider-editor="${sourceId}:espn"]`);await details.locator('summary').click(); + await expect(details.locator('summary img')).toHaveAttribute('src',/\/assets\/providers\/tv-espn\.png$/); const providerForm=details.locator('form'); const channels=await(await page.request.get('/api/v1/live-tv/setup/channels',{headers})).json();const espn=channels.find(c=>c.source_id===sourceId&&c.name==='ESPN'); await providerForm.locator('[name="name"]').fill('My ESPN');await providerForm.locator('[name="icon_channel"]').selectOption(`channel:${espn.id}`);await providerForm.getByRole('button',{name:'Save provider',exact:true}).click(); @@ -61,21 +75,23 @@ test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons const guide=await(await page.request.get('/api/v1/live-tv/guide',{headers})).json();return guide.providers.find(p=>p.source_id===sourceId&&p.id==='espn')?.image_url; },{timeout:45000}).toBe(`${base}/espn-2.png`); await page.reload();await expect(page.locator(`[data-tv-provider="${sourceId}:espn"] img`)).toHaveAttribute('src',`${base}/espn-2.png`); - await page.goto('/settings/live-tv');await details.locator('summary').click(); + await page.goto('/settings/live-tv');await openSettingsSource(card);await details.locator('summary').click(); await details.locator('[name="enabled"]').uncheck();await details.getByRole('button',{name:'Save provider',exact:true}).click(); await page.goto('/live-tv');await expect(page.locator(`[data-tv-provider="${sourceId}:espn"]`)).toHaveCount(0); const visible=await(await page.request.get('/api/v1/live-tv/channels',{headers})).json();expect(visible.some(c=>c.id===espn.id)).toBeFalsy(); const old=await(await page.request.get('/api/v1/live-tv/setup/channels',{headers})).json();expect(old.find(c=>c.id===espn.id).enabled).toBeTruthy(); - await page.goto('/settings/live-tv');await details.locator('summary').click();await details.locator('[name="enabled"]').check();await details.getByRole('button',{name:'Save provider',exact:true}).click(); + await page.goto('/settings/live-tv');await openSettingsSource(card);await details.locator('summary').click();await details.locator('[name="enabled"]').check();await details.getByRole('button',{name:'Save provider',exact:true}).click(); await details.getByLabel('Upload icon for My ESPN',{exact:true}).setInputFiles({name:'provider.png',mimeType:'image/png',buffer:icon}); await expect(details.locator('summary img')).toHaveAttribute('src',new RegExp(`/api/v1/live-tv/sources/${sourceId}/image.*provider=espn`)); await expect(details.locator('[name="icon_channel"]')).toHaveValue('upload'); - await details.locator('[name="icon_channel"]').selectOption('default');await details.getByRole('button',{name:'Save provider',exact:true}).click();await expect(details.locator('summary img')).toHaveCount(0); + await details.locator('[name="icon_channel"]').selectOption('default');await details.getByRole('button',{name:'Save provider',exact:true}).click();await expect(details.locator('summary img')).toHaveAttribute('src',/\/assets\/providers\/tv-espn\.png$/); await card.getByRole('button',{name:'Use default icon',exact:true}).click();await expect(card.locator('.tv-source-heading img')).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); await details.getByRole('button',{name:'Manage channels',exact:true}).click();await expect(card.locator('[data-tv-channel-update]')).toHaveCount(1);await expect(card.locator('[data-tv-channel-update]')).toContainText('ESPN'); await card.locator('[data-tv-connection-source] summary').click();const connection=card.locator('[data-tv-replace-source]');await expect(connection.locator('[name="url"]')).toHaveValue(base);await expect(connection.locator('[name="iptv_mode"]')).toHaveValue('fastchannels');await expect(connection.locator('[name="provider_grouping"]')).toBeChecked(); const eplus=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'E+TV test',kind:'m3u',capacity:1,config:{url:`${base}/eplus.m3u`,guide_kind:'automatic',provider_grouping:true}}});expect(eplus.ok()).toBeTruthy();const otherId=(await eplus.json()).id; await expect.poll(async()=>{const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json();return sources.find(s=>s.id===otherId)?.providers[0]?.name;},{timeout:45000}).toBe('FOX One'); + await page.goto('/live-tv');await page.locator(`[data-tv-source="${otherId}"]`).click(); + await expect(page.locator(`[data-tv-provider="${otherId}:foxone"] img`)).toHaveAttribute('src',/\/assets\/providers\/tv-foxone\.ico$/); await page.request.put(`/api/v1/live-tv/sources/${otherId}`,{headers,data:{enabled:false,capacity:1}}); }finally{ if(sourceId&&headers)await page.request.put(`/api/v1/live-tv/sources/${sourceId}`,{headers,data:{enabled:false,capacity:1}}); @@ -102,20 +118,28 @@ test('Live TV source cards show application icons and support cancelling or conf } await page.goto('/settings/live-tv'); for(const {kind,id} of ids) { + await expect(page.locator(`[data-tv-source-card="${id}"]`)).toHaveJSProperty('open',false); const icon=page.locator(`[data-tv-source-card="${id}"] .tv-source-heading img`); await expect(icon).toHaveAttribute('src',new RegExp(`/assets/providers/${kind}\\.`)); await icon.scrollIntoViewIfNeeded(); await expect.poll(()=>icon.evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + await expect.poll(()=>icon.locator('..').evaluate(element=>getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); await page.locator(`[data-tv-source-card="${id}"]`).screenshot({path:testInfo.outputPath(`${kind}-source-card.png`)}); } await page.screenshot({path:testInfo.outputPath('live-tv-application-icons.png'),fullPage:true}); const first=page.locator(`[data-tv-source-card="${ids[0].id}"]`); - page.once('dialog',async dialog=>{expect(dialog.message()).toContain('Saved recordings and their files will be kept');await dialog.dismiss();}); - await first.getByRole('button',{name:'Remove source',exact:true}).click();await expect(first).toBeVisible(); + await openSettingsSource(first); + await first.getByRole('button',{name:'Remove source',exact:true}).click(); + const modal=page.getByRole('dialog');await expect(modal).toContainText('Saved recordings and their files will be kept'); + await expect(modal.getByRole('button',{name:'Cancel',exact:true})).toBeFocused(); + await page.screenshot({path:testInfo.outputPath('live-tv-remove-confirmation.png')}); + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(first).toBeVisible(); + await first.getByRole('button',{name:'Remove source',exact:true}).click();await modal.getByRole('button',{name:'Cancel',exact:true}).click();await expect(first).toBeVisible(); for(const {id} of ids) { const card=page.locator(`[data-tv-source-card="${id}"]`); - page.once('dialog',dialog=>dialog.accept()); + await openSettingsSource(card); await card.getByRole('button',{name:'Remove source',exact:true}).click(); + await modal.getByRole('button',{name:'Remove source',exact:true}).click(); await expect(card).toHaveCount(0); } const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json(); diff --git a/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs b/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs new file mode 100644 index 00000000..69daf99e --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs @@ -0,0 +1,56 @@ +/** Verifies inline-key IPTV and high-bitrate streams whose audio starts after video. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; +import {createCipheriv} from 'node:crypto'; + +test('IPTV with inline HLS keys and delayed audio decodes through the temporary TV buffer',async({page})=>{ + test.setTimeout(120000); + const transport=await new Promise((done,reject)=>{ + const generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG??process.env.KOKO_E2E_FFMPEG??'ffmpeg',[ + '-hide_banner','-loglevel','error','-f','lavfi','-i','testsrc2=size=640x360:rate=25', + '-itsoffset','5','-f','lavfi','-i','sine=frequency=440:sample_rate=48000','-t','12', + // The default 5 MB probe ends before the first audio packet in this stream. + '-c:v','mpeg2video','-b:v','12000000','-minrate','12000000','-maxrate','12000000', + '-bufsize','12000000','-g','25','-c:a','aac','-f','mpegts','pipe:1', + ],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + const chunks=[];generator.stdout.on('data',chunk=>chunks.push(chunk)); + generator.once('error',reject);generator.once('exit',code=>code===0?done(Buffer.concat(chunks)):reject(new Error('Unable to generate encrypted IPTV fixture'))); + }); + const key=Buffer.from('00112233445566778899aabbccddeeff','hex'),iv=Buffer.alloc(16); + const cipher=createCipheriv('aes-128-cbc',key,iv); + const encrypted=Buffer.concat([cipher.update(transport),cipher.final()]); + const inline=`data:text/plain;base64,${key.toString('base64')}`; + const attributes=`METHOD=AES-128,URI="${inline}",IV=0x${iv.toString('hex')}`; + let sourceId,sessionId,headers,segments=0; + const fixture=createServer((request,response)=>{ + if(request.url==='/channels.m3u')response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="inline" tvg-chno="410",Inline Key TV\nhttp://127.0.0.1:${fixture.address().port}/master.m3u8\n`); + else if(request.url==='/master.m3u8'){ + response.setHeader('Content-Type','application/vnd.apple.mpegurl'); + response.end(`#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-SESSION-KEY:${attributes}\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=640x360\nmedia.m3u8\n`); + }else if(request.url==='/media.m3u8'){ + response.setHeader('Content-Type','application/vnd.apple.mpegurl'); + response.end(`#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-KEY:${attributes}\n#EXTINF:12,\nsegment.ts\n#EXT-X-ENDLIST\n`); + }else if(request.url==='/segment.ts'){ + segments++;response.setHeader('Content-Type','video/mp2t');response.end(encrypted); + }else {response.writeHead(404);response.end();} + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + const added=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Inline Key IPTV',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/channels.m3u`,guide_kind:'automatic'}}});expect(added.ok()).toBeTruthy();sourceId=(await added.json()).id; + let channel; + await expect.poll(async()=>{channel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + await page.goto('/live-tv'); + const tuning=page.waitForResponse(response=>response.url().endsWith('/api/v1/live-tv/sessions')&&response.request().method()==='POST'); + await page.locator(`[data-tv-play="${channel.id}"]`).first().click(); + const tuned=await tuning,session=await tuned.json();expect(tuned.ok(),JSON.stringify(session)).toBeTruthy();sessionId=session.session_id; + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&video.currentTime>0.2&&video.videoWidth===640),{timeout:45000}).toBeTruthy(); + expect(segments).toBeGreaterThan(0); + }finally{ + if(sessionId&&headers)await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers}); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + fixture.closeAllConnections();await new Promise(done=>fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs b/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs new file mode 100644 index 00000000..d9092bbb --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs @@ -0,0 +1,26 @@ +/** Upstream HTTP failures must reach the player as source errors, with safe actionable messages. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; + +test('a missing IPTV stream reports its HTTP status instead of blaming DVR storage',async({page})=>{ + let sourceId,headers; + const fixture=createServer((request,response)=>{ + if(request.url==='/channels.m3u') { + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="missing" tvg-chno="410",Unavailable FOX\nhttp://127.0.0.1:${fixture.address().port}/missing.m3u8\n`); + }else {response.writeHead(404,{'Content-Type':'text/plain'});response.end('404 not found');} + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try { + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + const response=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Unavailable IPTV',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/channels.m3u`,guide_kind:'automatic'}}});expect(response.ok()).toBeTruthy();sourceId=(await response.json()).id; + let channel; + await expect.poll(async()=>{const channels=await(await page.request.get('/api/v1/live-tv/channels',{headers})).json();channel=channels.find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + const playback=await page.request.post('/api/v1/live-tv/sessions',{headers,data:{channel_id:channel.id},timeout:45000}); + expect(playback.status()).toBe(400); + const error=await playback.json();expect(error.message).toContain('HTTP 404');expect(error.message).toContain('source app');expect(error.message).not.toMatch(/disk|127\.0\.0\.1|token/i); + }finally{ + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + await new Promise(done=>fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv.spec.mjs b/crates/client-web/tests/e2e/live-tv.spec.mjs index 662e111f..61e0a5a6 100644 --- a/crates/client-web/tests/e2e/live-tv.spec.mjs +++ b/crates/client-web/tests/e2e/live-tv.spec.mjs @@ -396,6 +396,7 @@ test('source settings prefill connections, report imports, isolate channel edito await page.goto('/settings/live-tv'); const regular=page.locator(`[data-tv-source-card="${ids[0]}"]`),bridge=page.locator(`[data-tv-source-card="${ids[1]}"]`); await expect(regular).toContainText('1 channel');await expect(regular).toContainText('Last imported: M3U');await expect(regular).toContainText('XMLTV'); + await regular.locator(':scope > summary').click();await bridge.locator(':scope > summary').click(); await regular.getByText('Edit connection settings',{exact:true}).click(); const editor=regular.locator('[data-tv-replace-source]'); await expect(editor.getByLabel('IPTV playlist URL',{exact:true})).toHaveValue(`${base}/regular.m3u`); @@ -403,6 +404,8 @@ test('source settings prefill connections, report imports, isolate channel edito await expect(editor.getByRole('combobox',{name:'Program artwork',exact:true})).toHaveValue('guide'); await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('12'); await editor.getByLabel('Refresh interval (hours)',{exact:true}).fill('18'); + await regular.locator(':scope > summary').click();await expect(regular).toHaveJSProperty('open',false); + await regular.locator(':scope > summary').click();await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('18'); await regular.getByText('Channel visibility and guide mapping',{exact:true}).click(); await expect(editor.getByLabel('XMLTV URL',{exact:true})).toHaveValue(`${base}/regular.xml`); await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('18'); @@ -462,6 +465,7 @@ test('large Live TV Settings loads mapping choices on demand and saves the selec await expect(page.locator('#login-form')).toHaveCount(0); await page.goto('/settings/live-tv'); const mapping=page.locator('[data-tv-channel-update="1"]'); + await page.locator('[data-tv-source-card="1"] > summary').click(); await page.getByText('Channel visibility and guide mapping',{exact:true}).click(); const select=mapping.getByRole('combobox',{name:'Guide station',exact:true}); await expect(page.locator('[data-tv-guide-source] option')).toHaveCount(3); diff --git a/crates/client-web/tests/live-tv.test.mjs b/crates/client-web/tests/live-tv.test.mjs index 82e7d674..8ebf1e34 100644 --- a/crates/client-web/tests/live-tv.test.mjs +++ b/crates/client-web/tests/live-tv.test.mjs @@ -163,6 +163,7 @@ test('source cards show counts and sizes and render only the opened source chann state.liveTv=data({sources:[{id:1,name:'Fast',kind:'m3u',enabled:true,capacity:10,channel_count:4402,playlist_bytes:2*1024*1024,guide_bytes:150*1024*1024},{id:2,name:'Bridge',kind:'m3u',enabled:true,capacity:1}],channels:Array.from({length:4402},(_,i)=>({...channel,id:i+1,name:`Channel ${i+1}`})).concat([{...channel,id:5000,source_id:2,name:'Bridge only'}])}); let dom=new JSDOM(renderLiveTvSettings()); assert.equal(dom.window.document.querySelectorAll('[data-tv-channel-update]').length,0); + assert.equal(dom.window.document.querySelector('[data-tv-source-card="1"]').open,false); assert.match(dom.window.document.querySelector('[data-tv-source-card="1"]').textContent,/4402 channels/); assert.match(dom.window.document.querySelector('[data-tv-source-card="1"]').textContent,/M3U.*MB.*XMLTV.*MB/); dom.window.close();state.liveTv.mappingSource=1;state.liveTv.mappingPage=1; diff --git a/crates/client-web/tests/navigation.test.mjs b/crates/client-web/tests/navigation.test.mjs index 0212be53..8e779569 100644 --- a/crates/client-web/tests/navigation.test.mjs +++ b/crates/client-web/tests/navigation.test.mjs @@ -149,7 +149,7 @@ globalThis.document = { '.search-popover': searchPopover, '#item-more[open]': moreMenu?.open ? moreMenu : null, '.resume-playback-menu:not([hidden])': resumeMenu && !resumeMenu.hidden ? resumeMenu : null, - 'dialog.book-reader[open]': readerOpen ? {} : null, + 'dialog[open]': readerOpen ? {} : null, '[data-resume-hold]': resumeTrigger, [`[data-resume-hold][aria-controls="${resumeMenu?.id}"]`]: resumeTrigger, }; diff --git a/crates/server/src/live_tv/iptv.rs b/crates/server/src/live_tv/iptv.rs index 0097021a..728b2e56 100644 --- a/crates/server/src/live_tv/iptv.rs +++ b/crates/server/src/live_tv/iptv.rs @@ -261,6 +261,7 @@ mod tests { fn provider_prefix_is_independent_of_guide_ids_and_channel_names() { assert_eq!(provider("espn.espn1"), "espn"); assert_eq!(provider("foxone.MyNetwork TV"), "foxone"); + assert_eq!(provider("fox_one.fox_sports"), "fox_one"); assert_eq!(provider("124636"), "other"); assert_eq!( endpoint("http://localhost:5523/sub/", "feeds/default/native/m3u") diff --git a/crates/server/src/live_tv/providers.rs b/crates/server/src/live_tv/providers.rs index 54e66cc9..8da66f3d 100644 --- a/crates/server/src/live_tv/providers.rs +++ b/crates/server/src/live_tv/providers.rs @@ -111,7 +111,7 @@ pub(crate) fn friendly_name(id: &str) -> String { "bally" | "ballysports" => "Bally Sports Live", "cbs" | "cbs_sports" => "CBS Sports", "espn" => "ESPN", - "foxone" => "FOX One", + "foxone" | "fox_one" => "FOX One", "lg" | "lg-channels" => "LG Channels", "localnow" => "Local Now", "freelivesports" => "Free Live Sports", diff --git a/crates/server/src/live_tv/proxy.rs b/crates/server/src/live_tv/proxy.rs index 3dbbbf3b..ba8e3d6f 100644 --- a/crates/server/src/live_tv/proxy.rs +++ b/crates/server/src/live_tv/proxy.rs @@ -1,5 +1,9 @@ //! Per-stream loopback proxy for TLS-capable HTTP and authenticated HLS resource rewriting. +use base64::{ + Engine, + engine::general_purpose::STANDARD, +}; use reqwest::Url; use std::{ collections::HashMap, @@ -79,13 +83,17 @@ impl State { &self, url: Url, ) -> Result { - super::formats::http_url(url.as_str())?; - let suffix = url - .path() - .rsplit('.') - .next() - .unwrap_or("") - .to_ascii_lowercase(); + let suffix = if url.scheme() == "data" { + inline_key(&url)?; + "key".to_owned() + } else { + super::formats::http_url(url.as_str())?; + url.path() + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + }; let suffix = if matches!( suffix.as_str(), "m3u8" | "m3u" | "ts" | "m2ts" | "mp4" | "m4s" | "m4a" | "aac" | "mp3" | "vtt" | "key" @@ -103,6 +111,24 @@ impl State { } } +/// Accept only the 16-byte AES keys embedded by HLS providers, never arbitrary inline media. +fn inline_key(url: &Url) -> Result, String> { + let invalid = "Invalid inline HLS encryption key"; + let (metadata, encoded) = url.path().split_once(',').ok_or(invalid)?; + if !metadata.ends_with(";base64") + || encoded.len() != 24 + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid.into()); + } + let key = STANDARD.decode(encoded).map_err(|_| invalid)?; + if key.len() != 16 { + return Err(invalid.into()); + } + Ok(key) +} + pub(super) struct Proxy { task: tokio::task::JoinHandle<()>, } @@ -258,6 +284,24 @@ async fn serve( .map_err(|_| "Unable to read stream resources")? .get(id.unwrap()) .ok_or("Stream resource expired")?; + if url.scheme() == "data" { + let key = inline_key(&url)?; + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \ + 16\r\nConnection: close\r\n\r\n", + ) + .await + .map_err(|_| "TV player disconnected")?; + if !head { + socket + .write_all(&key) + .await + .map_err(|_| "TV player disconnected")?; + } + let _ = socket.shutdown().await; + return Ok(()); + } let mut builder = state.client.get(url); if let Some(range) = range { builder = builder.header(reqwest::header::RANGE, range); @@ -366,6 +410,53 @@ async fn serve( mod tests { use super::*; + #[rocket::async_test] + async fn inline_hls_keys_are_relayed_as_bounded_binary_resources() { + let key = b"0123456789abcdef"; + let inline = format!("data:text/plain;base64,{}", STANDARD.encode(key)); + let master = format!( + "#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"{inline}\"\n#EXT-X-STREAM-INF:\ + BANDWIDTH=1000000\nmedia.m3u8\n" + ); + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/master.m3u8", upstream.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + request(&mut socket).await.unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: \ + application/vnd.apple.mpegurl\r\nContent-Length: {}\r\nConnection: \ + close\r\n\r\n{master}", + master.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + let (_proxy, url) = start(&url).await.unwrap(); + let client = reqwest::Client::new(); + let master = client.get(url).send().await.unwrap().text().await.unwrap(); + assert!(!master.contains("data:") && !master.contains(&STANDARD.encode(key))); + let pattern = regex::Regex::new(r#"URI="([^"]+)""#).unwrap(); + let captures = pattern.captures(&master).unwrap(); + let key_url = &captures[1]; + assert!(key_url.ends_with(".key")); + let response = client.get(key_url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.content_length(), Some(16)); + assert_eq!(response.bytes().await.unwrap().as_ref(), key); + let response = client.head(key_url).send().await.unwrap(); + assert_eq!(response.headers()[reqwest::header::CONTENT_LENGTH], "16"); + assert!(response.bytes().await.unwrap().is_empty()); + server.await.unwrap(); + for invalid in [ + "data:text/plain,0123456789abcdef", + "data:text/plain;base64,AA==", + "data:text/plain;base64,!!!!!!!!!!!!!!!!!!!!!!!!", + "data:text/plain;base64,MDEyMzQ1Njc4OWFiY2RlZg==#fragment", + ] { + assert!(inline_key(&Url::parse(invalid).unwrap()).is_err()); + } + } + #[rocket::async_test] async fn binary_resources_keep_the_full_length_after_playlist_sniffing() { for (size, head, range) in [ @@ -439,10 +530,14 @@ mod tests { ids: HashMap::new(), }), }; - let original = "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"key?secret=hidden\"\n#EXT-X-MAP:\ - URI=\"init.mp4\"\n../segment.ts?token=secret\n"; + let inline_key = "data:text/plain;base64,ABEiM0RVZneImaq7zN3u/w=="; + let original = format!( + "#EXTM3U\n#EXT-X-SESSION-KEY:METHOD=AES-128,URI=\"{inline_key}\"\n#EXT-X-KEY:\ + METHOD=AES-128,URI=\"key?secret=hidden\"\n#EXT-X-MAP:URI=\"init.mp4\"\n../segment.ts?\ + token=secret\n" + ); let result = rewrite( - original, + &original, &Url::parse("https://provider.example/live/index.m3u8").unwrap(), &state, ) @@ -450,7 +545,8 @@ mod tests { assert!(!result.contains("provider.example")); assert!(!result.contains("hidden")); assert!(!result.contains("token=secret")); - assert_eq!(result.matches("http://127.0.0.1:1234/opaque/").count(), 3); + assert!(!result.contains(inline_key)); + assert_eq!(result.matches("http://127.0.0.1:1234/opaque/").count(), 4); assert!( rewrite( "#EXTM3U\nfile:///private/secret", @@ -459,5 +555,13 @@ mod tests { ) .is_err() ); + assert!( + rewrite( + "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"file:///private/secret\"", + &Url::parse("https://example.com/").unwrap(), + &state + ) + .is_err() + ); } } diff --git a/crates/server/src/live_tv/stream.rs b/crates/server/src/live_tv/stream.rs index fbd6b155..502bf5b0 100644 --- a/crates/server/src/live_tv/stream.rs +++ b/crates/server/src/live_tv/stream.rs @@ -42,6 +42,13 @@ use tokio::{ }; static LEASES: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); +/// High-bitrate TV may not reveal audio parameters within FFmpeg's default probe budget. +pub(super) const PROBE_ARGS: [&str; 4] = [ + "-analyzeduration", + "15000000", + "-probesize", + "20000000", +]; /// Serialize capture admission with removal without waiting for long-running guide refreshes. pub(super) static ADMISSIONS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -172,6 +179,7 @@ fn command_for_service( "http,https,tcp,tls,crypto,udp,rtp", ]); } + command.args(PROBE_ARGS); command.args(["-i", url]); if let Some(service) = service { command.args([ @@ -288,10 +296,10 @@ async fn open_pipeline( if length == 0 { tokio::task::yield_now().await; pipeline.report_error(); - return Err( + return Err(pipeline.failure_message().unwrap_or_else(|| { "The source could not be played; check the channel, tuner, and FFmpeg installation" - .into(), - ); + .into() + })); } prefix.truncate(length); Ok(LiveStream(Reader { @@ -485,6 +493,47 @@ impl Pipeline { ); } } + + /// Report actionable causes without exposing upstream addresses, credentials, or raw logs. + pub(super) fn failure_message(&self) -> Option { + self.log + .lock() + .ok() + .and_then(|data| playback_failure(&data)) + } +} + +fn playback_failure(data: &[u8]) -> Option { + let text = String::from_utf8_lossy(data); + static HTTP_ERROR: Lazy = Lazy::new(|| { + regex::Regex::new(r"(?i)(?:HTTP error|Server returned)\s+([45]\d{2})\b").unwrap() + }); + if let Some(code) = HTTP_ERROR + .captures(&text) + .and_then(|capture| capture.get(1)) + { + let code = code.as_str(); + let action = match code { + "401" | "403" => { + "Check the provider sign-in and access to this channel in the source app" + } + "404" | "410" => "Check that this channel plays in the source app", + "429" => "Wait before trying this channel again", + _ => "Check the source app and try this channel again", + }; + return Some(format!("The TV source returned HTTP {code}. {action}.")); + } + if text.contains("No space left on device") { + return Some( + "TV buffer storage is full. Free space in the DVR save folder and retry.".into(), + ); + } + if text.contains("Permission denied") || text.contains("Read-only file system") { + return Some( + "Koko could not access the TV files. Check the DVR save folder permissions.".into(), + ); + } + None } fn make_pipeline( @@ -619,6 +668,40 @@ pub(crate) async fn play_recording( mod tests { use super::*; #[test] + fn playback_failures_distinguish_source_http_errors_from_storage_errors() { + for code in [ + 401, 403, 404, 410, 429, 500, 503, + ] { + for error in [ + format!("Server returned {code} Not Found"), + format!("HTTP error {code} Upstream"), + ] { + let private = "https://private.example/?token=private-token"; + let data = format!("{error}\nError opening input file {private}"); + let message = playback_failure(data.as_bytes()).unwrap(); + assert!(message.contains(&format!("HTTP {code}"))); + assert!(!message.contains("private") && !message.contains("disk")); + } + } + assert!( + playback_failure(b"No space left on device") + .unwrap() + .contains("storage is full") + ); + assert!( + playback_failure(b"Read-only file system") + .unwrap() + .contains("permissions") + ); + assert!( + playback_failure(b"Permission denied") + .unwrap() + .contains("permissions") + ); + assert!(playback_failure(b"Invalid data found when processing input").is_none()); + assert!(playback_failure(b"frame 404, HTTP connection closed").is_none()); + } + #[test] fn all_bridged_providers_share_one_capture_limit_and_release_it_on_disconnect() { let source = Source { id: 910001, diff --git a/crates/server/src/live_tv/timeshift.rs b/crates/server/src/live_tv/timeshift.rs index c63a8d30..9e3d118c 100644 --- a/crates/server/src/live_tv/timeshift.rs +++ b/crates/server/src/live_tv/timeshift.rs @@ -381,6 +381,9 @@ async fn capture_inner( "-nostdin", "-fflags", "+genpts", + ]) + .args(stream::PROBE_ARGS) + .args([ "-i", "pipe:0", "-map", @@ -477,8 +480,14 @@ async fn capture_inner( tokio::task::yield_now().await; pipeline.report_error(); segmenter.report_error(); - failure = - Some("TV buffering stopped; check the source and available disk space".into()); + failure = Some( + segmenter + .failure_message() + .or_else(|| pipeline.failure_message()) + .unwrap_or_else(|| { + "TV buffering stopped; check the source and available disk space".into() + }), + ); } break; } diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index d33b4bc7..c7e6b327 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -117,6 +117,114 @@ pub fn provider_logo(filename: &str) -> Result<(ContentType, &'static [u8]), Sta ContentType::SVG, include_bytes!("../../../../../assets/providers/trailerdb-light.svg"), ), + "tv-adultswim.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-adultswim.png"), + ), + "tv-amazon.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-amazon.svg"), + ), + "tv-bally.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-bally.svg"), + ), + "tv-cbs.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-cbs.png"), + ), + "tv-espn.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-espn.png"), + ), + "tv-foxone.ico" => ( + ContentType::Icon, + include_bytes!("../../../../../assets/providers/tv-foxone.ico"), + ), + "tv-freelivesports.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-freelivesports.png"), + ), + "tv-fubo.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-fubo.png"), + ), + "tv-hallmark.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-hallmark.png"), + ), + "tv-hulu.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-hulu.png"), + ), + "tv-lg.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-lg.svg"), + ), + "tv-localnow.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-localnow.png"), + ), + "tv-nfl.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-nfl.png"), + ), + "tv-plex.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-plex.svg"), + ), + "tv-pluto.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-pluto.png"), + ), + "tv-reelz.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-reelz.png"), + ), + "tv-roku.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-roku.svg"), + ), + "tv-samsung.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-samsung.svg"), + ), + "tv-sling.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-sling.svg"), + ), + "tv-spectrum.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-spectrum.png"), + ), + "tv-stirr.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-stirr.png"), + ), + "tv-tubi.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-tubi.png"), + ), + "tv-vizio.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-vizio.png"), + ), + "tv-whale.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-whale.svg"), + ), + "tv-wsn.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-wsn.png"), + ), + "tv-xumo.svg" => ( + ContentType::SVG, + include_bytes!("../../../../../assets/providers/tv-xumo.svg"), + ), + "tv-youtube.png" => ( + ContentType::PNG, + include_bytes!("../../../../../assets/providers/tv-youtube.png"), + ), "tvdb-dark.png" => ( ContentType::PNG, include_bytes!("../../../../../assets/providers/tvdb-dark.png"), diff --git a/docs/LIVE_TV.md b/docs/LIVE_TV.md index 214b915d..967c9be6 100644 --- a/docs/LIVE_TV.md +++ b/docs/LIVE_TV.md @@ -33,6 +33,7 @@ When no tuner is attached, **Find local tuners** reports that none were found. Each source card shows its channel count and the M3U/XMLTV sizes from its last successful import, when those files apply. Refresh the source to update these figures. +Source cards and provider sections start collapsed. Select a header to show its settings. Use **Remove source** on a source card to remove its channels, guide, future recording schedules, and series rules. Saved DVR recordings and their files are kept. Stop playback @@ -43,7 +44,7 @@ blank to keep it, or enter a replacement PIN. ### IPTV providers and FastChannels For FastChannels, choose **IPTV (M3U)**, then select **FastChannels server** under -**IPTV connection**. Enter only the server address, such as `http://192.168.1.13:5523`. +**IPTV connection**. Enter only the server address, such as `http://fastchannels.example:5523`. Koko imports the default feed's native channels, available PrismCast channels, and their guide. Enable and configure PrismCast in FastChannels to include its bridged channels. Set **Simultaneous bridge streams** to the number of captures your PrismCast server allows; From 83dcefc858a9b0dc3070639d13c2986e67536e54 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:13:25 -0400 Subject: [PATCH 13/49] feat: refine Live TV guide navigation and recording dialogs --- assets/providers/README.md | 1 + assets/providers/tv-tcl.png | Bin 0 -> 68846 bytes crates/client-web/src/app/holdActions.ts | 79 ++++++++++++++++ crates/client-web/src/app/liveTv.css | 2 - crates/client-web/src/app/liveTvActions.ts | 22 ++--- crates/client-web/src/app/liveTvDialogs.ts | 89 ++++++++++++++++++ crates/client-web/src/app/liveTvGuide.css | 54 ++++++++--- crates/client-web/src/app/liveTvGuide.ts | 21 ++--- .../client-web/src/app/liveTvGuideActions.ts | 1 - crates/client-web/src/app/liveTvIcons.ts | 2 +- .../tests/e2e/iptv-providers.spec.mjs | 2 +- .../tests/e2e/live-tv-guide-actions.spec.mjs | 66 +++++++++++++ crates/client-web/tests/e2e/live-tv.spec.mjs | 8 +- crates/client-web/tests/hold-actions.test.mjs | 58 ++++++++++++ crates/client-web/tests/live-tv.test.mjs | 2 +- crates/server/src/live_tv/stream.rs | 2 +- crates/server/src/web/routes/common.rs | 4 + docs/LIVE_TV.md | 9 +- 18 files changed, 369 insertions(+), 53 deletions(-) create mode 100644 assets/providers/tv-tcl.png create mode 100644 crates/client-web/src/app/holdActions.ts create mode 100644 crates/client-web/src/app/liveTvDialogs.ts create mode 100644 crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs create mode 100644 crates/client-web/tests/hold-actions.test.mjs diff --git a/assets/providers/README.md b/assets/providers/README.md index 9b1bfa09..cbf187a9 100644 --- a/assets/providers/README.md +++ b/assets/providers/README.md @@ -44,6 +44,7 @@ Vite serves the same files for development and mock previews. | `tv-sling.svg` | [Sling](https://www.sling.com/content/dam/sling-television/icons/logo-sling.svg) | | `tv-spectrum.png` | [Spectrum TV](https://watch.spectrum.net/assets/18.0.2/images/apple-touch-icon.png?v=2598111e) | | `tv-stirr.png` | [STIRR](https://vcz-ktest2-cloud-vodlix-com.b-cdn.net/u/ktest2/images/branding/custom/app_icon.png?lu=1792528577) | +| `tv-tcl.png` | [TCLtv+ official Google Play app icon](https://play.google.com/store/apps/details?id=com.tcl.tv.plus) | | `tv-tubi.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/tubi.png) | | `tv-vizio.png` | [Vizio WatchFree+](https://www.vizio.com/content/dam/vizio/2025/watchfreeplus/watchfree-logo.png) | | `tv-whale.svg` | [Whale TV+](https://cdn.prod.website-files.com/6666d37a275951ae96644f4b/66c7b2a5415c4dd4e1ca6379_whaletv%2B_logo_whitepink_RGB.svg) | diff --git a/assets/providers/tv-tcl.png b/assets/providers/tv-tcl.png new file mode 100644 index 0000000000000000000000000000000000000000..cc46235d690a8dfaa2c8547be5a23d8abe1271f0 GIT binary patch literal 68846 zcmV(?K-a&CP)bpCgqjhNOT7YIMozw>anrBNj)3+)kBRt&A z_yEC&@qhf!|MkC%1W1(rBgrAjB7q^Q^m~G+A^{9Zg3xOuSpJ)YE>{46A_)L2{R|LA z03?eTpjd?&;(i|b4gf3?lg}MshR_A+>kSA3<=4Ue5R2%ZDSZ*hy{J%a)0%Yhg)z?m5!&!+cG&tQfF&Tud@9AH{E z!zKnpOl}M_1K{oE_$q_kWAF9bUory@ea#-QfS>k7Kr{v9$% z1@P}opCV^4C@siDYW(@gZ0^CEr3LfrJtMij`=5pL)x5sHpF}Tazwh;k>CxL(95K@t zh4#a1WqJnmS6~L+od=P0wReBKqB8(G4>U8q@6l`Rn+2Xz1(G;*2+Yt%pjaHtRF##U zqXoFefd!HU5NXG)1EM%2iZehFh0*1; z{Dne{hviMWOoAAkV=HAP@XMB4;&49Owf$6jKusQI1(}K-_bwfL~5>qm)(w* z>t6DAW&a~UHm`YYdI(B1vm$GAx@Eq0V|!)_D~$LlKzEM}Nl#T_x}{ne z4D|$zb?ufhoB`3q(5^)PaY$P=JXM9VhOkOrUu!Yv96b=A6{OHW2@Xi1ivwFz!iIrd zk57AMR@qYnH6*2ZK$Y!8yuZG@PT5IwI0cmMNf+VN_sx zZQSpT;WS>s45mme+YC@8H&w*6#BOHhu((E7J1il6w26(Gf1bkVDzG=lkSG*mm>Yw~ zzN9B^nW(4u&fq!PzM8V3n~PeGmlE3M<9m}ghy1j?Umw6I12sLJ`|rUi%0fS$y=i&e*;=;alJkw5>-Yr zZIoG*LCN({Q~&2}aKtcu0w4;V*X7E85Xx zV@u0gHk%{mgkc$u?XncOduSn;X+i#;#vc&v*k^`V6tFm1D5U*7@k`~q9ssW%Yzp?e z1m4zu^vJ?XglD8)tTtx-2f$y0eGA19V1FEEj8eUa+^sFDo!G+1N+pp1{zYVP~#2(f#;yq*aMLOX3I0>)}z zv;fwTSZPJ{KQz#|@)2}vPaL;&JfNryA0&OK+ zfuE9=L~1g`vRpF@z+|XJte&mnteYQX056_tJd!O+MTPYIHD!XSlZ>|VJRsvz)sU5wHJ7LGvzLExaq z#{H*f%0_-LbE;(QVn8q`6o3K`*$tf1y$a~o!nWxf9zuE`qrtWR%Srvr%u{3!h8(n= zZbip9d;i11v9ZR0^~|&^YeGu@0-Q1s+R*kY z>#Knt@RU6V*xh;%p$&ikU|7QmC(w}KH%Z_r7LK65+#TX$2`M?O`~vvt;sfwY2rO;%Kyj7e7AR?0FOlw0e|F(W}!w*A+j^M3b z0M0{(=z*;SXV`$vx!^oTY=k%8*g)aZ_3xwt@X4ESY=9=H&=S%!`5k(OaiNMbYLUZn zXxy=Hif_y$Nca!Hw+E#tcSxDaR{{~B!yZg=LYmo-4`1Dy3~!IfJu82s@dpR5QBG`~ zA_!2 z5%0{H*%2?r!5A`kw6r*nRC|P~MMOjBLfY8Cd8D`wFAX-$J#9n)M_FwsG0Q-}aDN6< zXNg|m0U0MV#!+KFYl%E*!W#OwckvZ7Ht3&4%xbftuti}rn)t++g8|-<6ygY;=Nu52 zGQD3>ME_@49;O^6FE8gTg`EOfQW_Z9D_kJ;81di^CiuuOK4+c1Hn&Rn;8;Sd<_OGQ z$j>E;SjVWq-@9gl`)^LwoP38PkrDMqGwJrry5?~RgeYev;1E6-%RS};>_|-Yg1?YT zHXInm)UfO&ft;DD?oY$9k=(!DNG= z!4j%QTbK=^xL?AVI0xN>uS*My_3>gyZ)Au-K`^`KHo4zZYdKpS3xFANW3F}{s0KB| zS6~}>Fz7b=XgmaYHiqUU0?V9_<-dyd<^aJd=hjNlOz$}b6E>iod#=sePys07obs+X z+2|NZ?ykbqNNL4PfCnT>;Yk!;rU3xy=)v0rfaTbJUsx0~fyDZvE0QQ13L&gD`DWS6 z#2b}P!@75zY;DlyO;gzmBvrV^CvaN$kKM1gnh_%)p6*&4wgsjVaWZK+IeObNaNeXg zQT2zf1+QiQnbaQbK^}=PkCGTn{_s_jM?M9L^UsFSy5j-n@pY0~GkHZjM!V@7aktc_ za2MY{Ffo9*E63z3BN8Eod6b6+{!ppMe81&mjnsa}cf*bSLJ607dI0F|ReSqU{p#_i z=jNl(F)l$?GTx+F*%{$e+gWjD&7RId`D&DiRU;L-N*t9754# zsGhfH_^!8`jkz1ZE0JLVI7bTad*shM_;U(B-J%>20|&fT>|=#Os26*>DH&=R2rc`n znxN7ojC*51>=2#KG^xsx&jVWSR3I(hnV5&va~o8(qF^~jte;(&(E8P7f0S{1FU1C z668RemYC+U?X_V+3Cze&h>fTP2DKLW)QJ}A8dV}$ED%b&WI>St#2GH9vTQU4*IVSz z%%GeXuOle)imdcSWk#Gu&`ucAn%CR=lmF5Cj$V{^_1~j7RJraWo_hv(VpIUUJj%t( zBTw7710v?+xkTyD%)CANd;=`-XY8HL;59JM3|^CyP^3-s6G>>LP-lt6bB6B>{;4w{ z@H0urP4Ya>IwQD@FW|63=+(fFGr+N2zW`1li7Fg(I9pfXg>($BdI*>xSptGv9L64=BSZAw(hNOsD_*CONO|3?{j!*rg5a5&PHn>?feAM# zxKGpnHW&5Pi3u# zD*y<3V2sJo{$}4I+R1jvku8COyoR)J>e*PpAmx+3Nk~zS-2(j+6p*UkN<1_SU11_#>h7@&ao4GrMX z=ymO7+(@BB7#`hsduN4$tnEgXszJf#F#&}_L6-o~-8g>=Y?5d2Y76n$;K3mi3WK)n zl^A#j3sRC6u2^RxLIKuM-^9mL%qMrZX-28KOF-pmdRR3u=NP_fb zw4iT$Wvajdo@1z=D!n>H{JE?r;@L$M7KSK=3>M@=Vv0DhSBFc6ojCx-6F5~k4i_fy zJ%G__x{$t}5|k|m?#edsdScF2E+M*Thg+5Ce*!c?xtWxNuIqOMBIfwHL4Q1hv7~oO zZ>>O7d!MJiWSkg*^))kn4#MEs(nLU(QruD!EBf_L7NELuv_lF$O!T2=Y?J2is}h`9 z-oar5!JbiZ1Lf_ME(EI%W(F<8;BW;P9tc%D<9Y%%6v`BUE-ap(0bUNzbCkgtp@yR( zKE6_DaTmb+k*MQGMkZdA0Op~Jpn{B>z`+5iP|0DI_l?Pn$Ph8riby`k-e4^duD{VQ z-qlZZg$^PgU`z`^p)$vc=|D(MJmG zvS4oAJ~3kLNsKCcR1Cb(TeSrVE5;u;Du)+1+`(1`iQz5au-08{7@~g|yc^Bi@I)p) zAwrymbluP%sZEGN1~ufh!M(xAi-mFj*eJPVZiyn?4K}))WH*(Ab4t%8u84r@Z#v}D z4ZN_0;2I1YilOPI607fI@<@|*cuctUG(p17O4O0qI)N8p@5v}~+Rsom7|Qv) z-8iM9%Gciiotf9mVq?Pw@nWU~t4YV}1M&r?yV2gAGsGO)@QLw%u_1<;4jWacQp%DJiOw2T6OE>pDxZ&^{gri^}djR3*lLtf)^~;HD!yu-402j zZ%ZypyWRLhtpz^G0muD;DRh80;5?5#fukl|-S_KQmo~G%;z#*E76wGP7r7)A`IQzP z5ek07!smt`|KbbQCIOC_t@Yuf-*_{qBsKTwES*TZs*Rs}lsem{su{l3=BQ(Tbj5c% zB z)pa~&{|+$A8CHH;cnE#i?;vD)i`a3c;8b)SVNG&GiBV$#QRiYMY~v7uo|= zM}|etdD1!n85XToRjy&Lzea;Gb2d&g=g$9&__Nz^I1ncSqXx6aur>Jwa;MtUzkDaT{qJxVE_KkAHB3%fQ@V1 z$5XVSQ#D7NN|c$tSwbniI>#udTwEoSSMdT82yr+rA~ae!_TpT~UQzpN7(Nh*3-f8h&-A`&d-yexRQH>LItHa&rBqMebBf2} zP=iuFb{&T_HN>J(pCc?cGpQw(SY8jex5HM$_1+5{CLUfU15QA5{k&X z%$x&GYcXk_r>ifaIRi2bvz6c8r2tI3UozE1iUPDDP4RbclV{xEFh-_SBSleUfE~(H zr&v;QE`!eu1}W$H1Lsx9U1s$ukeap*1s;&O!b z;?_I3S}#PP{x1zeF8LT>4v&2bXViXT10V@tyoJm!ofre4zAIa(!HkWwYDBEo55p?4O({5=&vjl)7LfV? zDPiT-UF@^G6K?mNyxtMt@qpdHo36MSD|NB$ewG*stS+@-=QHmN9u|H^2I{9&cknaA z2bJo?IRJ(=Z?_6yN12A^poVlN0^{2Gv0NOl&!fU99G4aY7w{$F*>4mpiqM2~9AUOMu;xun1!g!G>OS>PYZq#2EUY1=ocW3l$6uMc+!; z+$bcRH{Ik>H{q}b{kq;D>!2C{VuSX|e>Tp)J@@B4tF(l2{sc%B`h5BaoGI@b>{2SV^h|7<#k-StB`Uj0n*KWJUdnaL7=Sq7B+kB5()v4u*3>$ zK^~UnY3QI|#z=8Elo@@e@dywiAwi(5OK90Mc&XPNiwm<9;F<{`BrEbj!$;H^m_|A+ z$g=keclZMH%!d2y0sbNJcGI1JP>K-*YeI_u0AcEo!rjJj`lu65MsWs77b?UEB6_O$ zcVQysv^v1Rch!J`72&>>JDR?XW~TYXbk(o9?i`m~Q#B7&{kRs4=w3b1W2}5a77d0_1a8DFevn|`$PQLZw;9*2cFDH|1lm~ga z&^R4uKk>|x{+XKGbEonMGe13ioR4m9h3U9Qf@bS&|1QK3+pIjpI2#k?E-QafnF@iN z(wahXWF#=GM|t-@uq@B89hwZF_<51PRITg zW=sS={T=MVoVG=8SwU#Cv`T|ep$K2(&;UJf7op6{0jfsx*J2W>v5sM(h zl54Fj;Sx)s7c67(xO`pF)hJ^K8Oj0qFcg#o)}LcqOgE0S?Mh93&2Xx4Jk6tRvYbis zG#owq`!>zj*lAKN=`n~V_pFeZ9{eHM_OWfl_xiTtCH)W}RdJVwDc2{|+p2mjWZILS z>N(-s_3X#{-d;e({DXANfetgge{iUCgv{IoI2$tzZoY?`DUO%Wo+W`;aN*j~AQCtF z0Pt*xkr)mRn1qiu+k-zu*3D3!e}rks1k=oU@8_8;z2{uT_sa z=b8e3!hyy1Y9tb`QUEb1vcl;`D{V9&K%(!Xnb8l_2eO|=6lPBoHXzRtnV>mwo<{b#NT+)BO}_;xwj8CB>VhNMzNY=|sv)kf1dIf3KFk^?ZD zSY_=IS7b!Nfsr? z(7^mdg*~q?-O$hwXH4cVOzil2f4lLglv3TvMIg#))EKL@R0SZ!(29F=Fc%IEn;jTH zH3tcCx-n@>r<6D~845XO;E51`B3&qvX9LnNQi5%OsCyRTnX=(zxF?ZFnC?lWD(h*> z!v?jOG}E;l!LWzNbq9*gm0sx=JT*^AXV()8k(Wzu1M_QPb%und*eC72YW#tRO(?7g zlujtHh9YDk?MX~1ic@eGpbd$$^ofC@*s)m3Q(<+~n|4vaYA)ltN0|z6?+>fwvG=o; zg#c0%hP7TJyCY>(G- zcU)`E%sw=9;5$jA=rbMHpF8s;5vw#(m(l>9B7S;256`k%Y9=E?09 zHwEH#?n-%khYjXne?UA#G^q|p2TDIZIHjT^I%fZ46!4?0C+M(e25F4!y(sb zGU6DW?Ld!dYo}?^kRIJ24@YH>2>Dm@qF>`SrwBEG(e=qJpkF@6T@)enD!F{^+{fE9 z1wjfCvf*Bh!}6Bt_q0(7M-WI~EF*5ayi*7VanV6i4{~Mtm1FtOwUZ{ih~q1aT*O-QVOyq{IaIW}p`StAk;uWCCd=IGjV-7amxVHb?cF(d$IWqOz07zQ1oNl@T% zQUwe&8A-*3YtaTo&0&z>tg`F>2TyU{ZdHHQxPs3)1`)Lar7d`=sWr!tVrwp>tS6~a zk3X-PO4XWc_|>Z>u3@00&Wj7UYGH~Nx}e1x4E_P}qZOWmqY-5=R0Y6NQEW0B!lJ4- zaHNI8@W-JqrtFEeQu#fP?f66wh*U+Ee-Wf$mEOt0k--cDtr>MPk(wQV8`>f@#W z4{d>gr2o`$IKxZ!?R9YBxm-aS3=78mrSh1wpTY)u2g4MACiZaHVr~KP_e1cpSKKe& zD>7UH%sdJsl>1*x0(AgGOWFwyho$1JX+P_x$~DU_h9&g}BNkx-Ze`6zRiI8Gx@MX& zQo0!qfFBpsVDhr)C(7rz^n?pTaa7S(hwdCyHcL-^&ly0BNKhDJ%hf>`gRE&n%ARcs zSP_xX1PG#)3bZqQiu`Yv*oNOwzC`2LU+dM3!lzH|CIIm7z@@=Sn=e^b001BWNklf_EN>7E3Rg-6YG6`6EzXd?o`%_%6od5OICq$oU{2N02 zPUW%g;1BLZAeZzxQVlr4;JT>ybu!)_K3i)SXBsXHaV3)_00bQA*VU5jP$it&pmbQTdY? zlOpuaDYh7V-J>fDkuXCIKTNuBOPX6ogoh?|TNF{ZLWXnZIouXooR1Q_Ku54W%4a3H z%)gZVq=Z+BWgb8d6->Kvpnblt#?itD!Ykb-{S&Wn^*Z)1od%!J$TySFx~ali2%S;o1M=+=0YxWW)g?9=Ho-jClfdjp%;m3|y#Vh?H<+v=dzU`D2z8I7j2Tol8~ok1)Ayy{R<AH4fSpS2QN@Z!%t4Q7XrKX4ll>^4;~GhF&XMC1NmlnCzFLhkP;+e7!4Ga)V0Yx zaUZ<~{-*N|#x)$Nb09d+Imq0<@nl~^WX*Kb;7?N!8VjTb!sLTys|9_B&>a%<4xgfm zWRjatRUn!;k6C{!NeHHR-Kxvt`*ntHzooz?2TR38>IMiR;3e;>HZNCCU~0)8$A?cK6rUA+!05KyB`MV5Xu z4fOEQIC{H@sdDFw;JQ2WaGIWyn*tB{n;3+AMZim&*Z_bs6?MN*_wdf(pzxwu7m;kV z`;HC@X@jyQ61CWw<&Df1lqnJ)LYFlw8v3T-2(l)i>c{dqR+)y7L=Fg{v`gt7ts5{0 zESOu+j)3Glgv3=uVKPEm_A+5PHujPWt%INVP%X)Xk2C{L!YNnw9gGds;MC*(|0uAE ztW21$4ona}9Y$zJeXvivJ3OFZ09OJU74X&xCQUdH8W7{-`bTJHoejO_kV%lc2lY)u z5AH7 zNY@s;L5ameN|K5$qotHXA%OkT49_V~6H>tYPSh+eTTg!oG^Cif*v5X{D6gnk#+8DG z!s+a@K~Oi60bRm}{98GqKMwwrxN|!)Jg0@N2^%hA0)JYmv2GdF*KAMPnsqyJq(Of< zTCJ!lLa(_4g+DUdTjuT>&2Af=ge5bK-6FB&(H`$E_%b(?P?TT;g7_a^YMC#(n{^2E zkq8}U2Ex+_$-${6D5*N?k3Ky)8f>EK1u4Eqgt*_%j*@57eTN*LvX%sa=QN-#DpIe2 zuZe~FOCtKi$`{XWtipyu^7dTA5(AJ%>QOoH}q+W9+;W9j`FaUk!=ur)A0z?{VLLXYmf z7YXWnPt{nU-F`1X_k}prF{8YDuT_@lZPk@SA5Z$R!IQlE&GcH-6lPx2ALGmrgbYN? zWWq0nC>k@3Iwp%-bw+3orhzUE$?cwP*-)(|F*Q%R_0Dl70NcG6_MMpuyu(03>;Vkv z7s&0tf^o^;GWb=>z3&Kdjh_;SxZ#3a!U;?d_h3&>8xrfE2J4SBd-;HKrR%Zh$D)}F zMe`pCSGryFvcI!WzX*d@$*-njHs6j3*A6B#lY8iF@-=jqD%#f)??;)Sx&KHmt>=CY z3}6?$d#u#$UnCErRGEN9;O4F4*OcJ^%H-}Um( z<6wI5oaExA$ohWU;>Ts;R`Rq?XAXfyZnSLUI%mT;xXRb&Zngj@e-C&RPQ7m{bED;- z1GV?ElNucFj@GmfmEN6J_;`jI6+D=B^<}qSB7p8m_6ak53R1X=f+g3F>+x2p>Dd8B z!akT`Q-)jCdqgTGTv$FJO6WlytitE40cP;Xh59$^pr37#PQ?rC`(psi(K+sycr70g zLj@WL@N-rm*9cgwWjMCbG%qQM*grZ4X+@m5f0ru`J8*HmuRg14 zzghSz)|$w)`+-tVxlbwgFvQh!WPU_q>GG*Qn>7ZXvjqtQelYcOZ;Xd}%v7cKK@)_F zeP+Cqg0wwi7McKfrrhQKA`cUr%d9tnkem|ao{J7&+`W?ZAT{t-^<;o58gHV@$C3|m zHpzPCzdc0b0QlPjKBZ{CY7CMcMz>CkcXl7So10L_HJ)}c++eOw9F)09iMnhAI zFJ{h!3F?%QYdJ=JA15krh>vX&>B~Yl-B#L03r!xXJCI5PLFcN;5;uwNc@A&R{}?$3 zY=`|nwT%638;-}VkaBv%8i8a5Rx!zJA_2(s1ZxWsSm)rYIW8xI#$u4xKp3%vYRx2J zP(9?Jao)8sJKF%MUD&QfL_9W@1uv|R3#7ky2{l`m_jRdU3WA~**arA|;=TOHOsI6Jibrmj`gCslyQq=KwAVVE5Pq*ccdYD2HtVE{#eP1Fc+q zs^4(S5bXPW)ceqbKgn9sY7+p-XuNmPRl_e-u%Sb$5U(wcdNO@n(1E#f zHjTl_bCIBWZ14{F56|>W4n4uoV8919!??_xCm$nhr^4D6r-OvPKmD!~9j|#g>sE-N z*Ul@W*fWDn!Wx}3sRJnPk>z8kFOqV*`aEEk?ZGB!*NBWGP|4&>lXWoNtV%GUP=YQj zeRP^ns@*CwkK%zd{J)eqr~a<87nmU+TlMF`9l2Vp`?0}5&_UVURK;_XD+469Q7EW9 z=-`VIu91-;p}V(E@ad)q%T`6t2aI$5`PPl`Bk_oE2DT?zMM6az4w_T<4!8#=*d5U@J0_RWe0fLEw8SpH*MbI z^HMquEy6QxKvNjS(tZG9IHTD7g~-AenmN!hK1PW@JvfFRKVGAg6;G32_J8K@J{4AJ&o+i~D9H3B_D)|m?C5_jWs9E?D_MZ6_f>7L;|3Ty_N6|R!W zs+#T>>Jump5)qXToj@efd~CJGYP#jS9_u~n5WKNIv6+JP%7ae!1*bcNCfdX$l^uA_R-1284{cOu8};aHfPd$`WI}`x zg!Zx%em3U~DAvAkxr_=-izg`95@Kzr;&bM=-pw+Ub{w)Ym+u1N#gQ>gn=vRuf53(( zH3XX(!2TIt5=$dAO~OP8uc!mq;|YZB)C+8nPzc>srHv19H2Y<;KmHgtokxcX8;eGP z^*`U?6Jzr=siXDPGXgxh{L~0|v;}HXrDiUJ;sV}rl%r;O_uL3w&f92RfG^wa43qskrB||GV2xJ?o{v804G@slJPT=u>ll~pH&X=){zLGuxtM==@H1W)Z3Oi&J?1wECTalZXB5#Y9O|gI@ z0vK?>hyoevvb#u?6>9`|;~orZtJp5#P{1K;gbqd;Ryj0!;mR=)kf?buX8E0VGcrcy zSWj04Is>rn!R6v182}<3e_n4Bgj|L8r--_cKhP|EL571!PcPzFh`w<`z7zW$!|f;7 zFymKf5Ew#-CPp~2FH9I5gJP96*<`jBMjHLuu>An+$FMp9s{CTB1D!p$@cFY78!(Bj zt;~jgld=F+nvXp%V1f)IsOwMk^3oC#9??@BLu-gI2852vhe#D3R#ZIjGnZ`e(uE4*2$C&-tie#kOJA*$3{HTXR#+o;J)mA|7V!T#3mcA*0^tcAhBd z4y6H80(@MRZ>anw^lT)U_FzZ^Z+_qZdp_fY=-(%9zl9Rog)K2&BVY#|f^Oq|TqP4q zS!{Sqm7N)=LhAElFp3ytj5uo&Z?9YR5`%CJTd5D7Fp>L#2JnQLeE-#9Z*~kt9GI;%vkf)KsT%Ju?B~+=l3al19s(xHME| zpbSNn!g;B&;8@0=Ul^`t5N>7QE#SGSKZE&80LJSxiftwKd;ugR@V~$BlYsanJjeU- z;9Ej0)$-tyq%RHO51{~r6I1VkqZ=;plhzknTx8i3AHTN7gyPL;4s6Lb*X|B_plG<^n>ACcK* zCM%I5R1FDXG#GOvXZ^uNMhslTY+C`R?dZkJX+akq*zjs)>}9bBj?fmhK{Xqql>4r< z)Y4fB%5eF5PP(r(hwpMBmn&gEI=ixy`uiX4{}96_=O30jBoFZe5pGjf*8(cBxQzXT zd~`ZhW6ScaZ8F&gS;pCmHj?idJz4R12(xM5aX-QVFwHK=_-L@8xY%(vz$aPzGY*R) zL5GN8Xho-w^vDWoWhm+kc+{|g2|1`}_?Pm3lzmft-1GC8bDC^(N*ANrbr~9}<}b45x_n<>`Ez|kNrwC>yHI=D zz>gI1$Qp27b+G6g5|i6#F!vdScTtoo{y^8G;fJbP{}~S(nL!Ql+L-=8hy>Paj>ev0 zhJ8xYi&$uJP(}kOo-*=_DKf%Qs9ZJ>K&jh~S!&aYI92!=;2&T^lNa!OX>Q2%ZlLA+ zufmUAg4($kMd20F7?lHq$CbJuZP>lHb`zfCL05LYuNv9 z-k$q!`FMWHbblKRaN~fl;Yopy+lCx=xFSS9TfaTiAl|_17D+Yo^gmLOYP6ohb`8aF zEFEMsNi*6xG8y<%vi|>7{uK7;{!%V1RG|0YYBXu|OpJZ@!~)^7xZe(3Wa3YTC3gw1 z&b`xb>^28@(R?V6+y*B~-=%PgMwvnlImx);INB)Znba2mO;_~! z2~)bRg@LOczDSJV;S#SZIk<&E2}9pqGnku~tn`8~uP2qs01Itnn_Q$1^Gb zQX0aDmmX^^&S7EnESIjA|F135nMkZ^gg zjfI$zP6>*Kxu{sFw~wNmU>nB4hln%?HXgbaxa9ds4el5MI~%|rh|Ujq2ncW7?*G&2 z!CbeO2A+6|;O|;R%tre?AGV%$Bbi1bXqGtP8jG;`x56(!TdS>jYg`74CP13mk3!3z zR%QOX$;yF-S6)5FBAboxt=`;R{aSaH;{wN{y=w}5I>zAHCQ_i(rQ2*w;$nMYWl0QJ z&Zf--`#aa1H7t7)&PW#L76Yxtm_U5qSeH&e8c?(M4&*+wY%=KJJK>&(r_!mm#!<6J za&)$AQqtx)B!AFeAKl~Q<|IRmloa!ok(pzh@!EbEz1=4Nd(;CGj+M@fKtdS`aUjvs zlR-5zcnbJGFY%w}m>2|pjG&0Zn>2=ELco8;p4DVlqLkn98rB~HK-K*8q&(inz?GfC zL9&bu6zh%^nWL;gL8k*SPN|EyCdu z351U|8>RBLcE-VjY=e8Loz=m@3P~+?Ob@TI`8Xp4=ntv3=;*NpHWQqqS3+H~_>o?f z0nt0TX}>idw5Kl+uhW&_x5PMg^X3MObg<%K^k8n1&zQh+du$BnIQ`@&e#v=yG-EGz zHC0Bgmzow~oC85TeG23J+YIoBX4y-}i{;p=w%4v2ygDisx9C~>=NcQ%J;g&p7StKi z*E~`TrBZZjq}MUb08>C29sWbz`=93?4aOP#?ZI<$Y?|W#oKDo1^;?jY_0zrQbR!?b zLXB>h^>55Ps6_$bsMT0$2GVr?U{L4iv|pbtqpmSw#B)Z6wp8w+wjhb4u;V$WK$H&q zFqFRIz+wm#mhEuRK!2?AGfJr$g134QF$tA49h@XcMxe0pKe7MD1>Q8^b1$EN7dDpP zv|)@+S)=Nsfw4xsFhF^u1cqE2Rx{|j+F^tZ4{q>7L}@HIHV!iZIBR9r9H>!5b@pZB z21bFoNW#;8d_$NA8A6EA)IV@;G&eW6|-^Bsby zE;Z(GPw|BUY8@oukN|+k$7=%4tA-Q2L;R69=ZjD60d&0qG3DVVQPfo`p(Yr~SaKX{ zXob?!(G}mUL8HWNok5gR5~gr#lvT>vf102q4&b=T@3G~7ZHWF3@aXsWJ2MAV%>Y!? zqrxeVQ=9gQQ;p*bZi2R+9qQ)5$Vb_nVm^M>!J$*H^1=%h&LFm9}(GwJ~VPO--1 z3yyJ4V=4tiA_(hvCg6$geNum0=_e(%VI#hTcwsMql6e6C-+G3)Q1O>W=5d}&!dKmR#HEU!~a-`Ng3f}SZcInDnX9>m}fQ0#tB9A9T&qXYnc=wI3Te0v<80{X=9=5lhTuWbhJmB$7XU+JVOB03O&ZF`&t5e z27kC;FH&;~hsvpR0y(eIPbal$xsoqeO@ZOR;HaiK9ga_CDv^6Ls59vRWLTU+Vbn9A z9b$tRYVb??U>ZKVklk(vz8*Tcd##klgJVs>vh!X@qbg+#So?cJ_GR9+dBb}kNQ#JZ z9UTvzG{h=@{?5G-`uBzx)~SmQXsEJl8;vySzL^Rb0`GUt%84v>9(2qg`90eRgiBJk zO^Gi&DH2CAQGU+6oFsP`1IwKty}o?e|FPf$sSwN1NQ4;VM~<-3tP50U{5)3tE~au% zv5m>Qv4kc**&2BiGLBv*;kGQl_>g`C(g~*nJW|l3f|~(MqQNWa8lN*s%0e=Va_AS0rB)7!yl5hV?$-wO$A9cPefu=v~*~6IWliQfE<}C zGGC}t@V{Q|+e1NYRh8&lLgPL)9{e;2v^)k+?=%h%{z8Ah<}!*p;!RnPHXoo9J~%n~ z2gno>;($k|6mirPH6H%LLYP-Rg%8;W!+AL7w@%$DKJju~?=J&#u3^wy1_bMr{COBg zq-0~h*AdnBF$mr&Y@UDFefOGoCmvhJ@qrqo>pA4Vid~D^=RTErCkcro}zt{PU&Kh`ow-JCQ zI{z58{Ov*2;HQayjHG&OHTohpS?5r7GWm4qpFQ@pP5hbqR35g&Fp+^ZoR^Uwr3N!E z5YE3%yI(>50X{=8oDxJ#PvglbZz*#;v{vWDTQBrS^8Ezq>cB^zugnUty{lLo-RZ3z zn9Jb9-X2-(|6e(8&JDERb{X-na^K|yK54`#)vtd!j#sjcQ*+2}A z2YULoeKrwf44IBvUpy1?S>f)C4*ojX}%Y~E@7AvLgL zPdZj%4xk|S79klmP)CEeM~aECG%RO00Nr>2^I~l@$|X|~iyR12bqA)v1}q1NoI@K% zC=eR}ql`tkwVX)Wy7vWu4b5^iipm(U6iE?Km^G7RudZDkgH%fE$=U%0K4=|1ki356 zs0L~SE?Z=!EEVh7kW8gM0Z+<#FM5_{o`eHYGI?7DQwetaP?30VL`gqNZPIX!Occq; zYjgej(yP=d2X?={(zYQd@ykHmF2N`iZLRZ7H%8CTSG#bP9*`7~Y2v?VV)Y1XFtWLB z+1;X|*x8Hr>-&*F`fUb@aa!2Vh4Twe7Q*VA5>>9(CXy$gG^c=u#u!~YB%@%s$NM{I z?GZp+R%rs2<(X-i{t_RUu`HR&GsbFTJvm1`vA0p5I1E{Uo9Ky@M zZ~iKt(xYpzmPk0|9-1|0vL>!7w*zlqYmA^O>yOA4!X4ZBl*vMonWqLntz*c4X7GS` z{Rtj;aH~S3O~XO_hYFy)3O#5nF3}iwYRb^yOjS-4zNGo4>2JqOg zpglC4A$WO?jKHJkHFN*f0mQ2ctAsQQ0k`U84<*N)ph1cw0AK~LeIKLbRR=fLw-NAz z+VD)OLpO^M9zuM8Dp2p1lSV;AJOrSoF zH(l!xhtI3Jz8!e!5(~|~uu$Bp?rRX=Qd|>JAH%VKD5YLiLyVBFSC9fHO};#5DaT*# zE1Ru~EX}4T@lqLtS5Vztg148Pc0x6igjY&37KgV5Sj45huNCYIpJK3$ICSq66W@w5 z@U+(Hbqbf1;c4dNAUPa&O@NhgWcS$}PH5z~iPCIF?LOSFU}rCc`+`~jA>NT#{iO*= z@8w^pGdBwNj#1Yko;mpGwro2LxZVmlXMj=N#ti#^hnTBS?$@ zML@d0Kz}}CM4?_m+bANbw-~Jo(RV8o{b3>yf3Jmj^lL3x_y7PP07*naR3OFN&+-FN zF5Y65%+!mpPB~7M{OMgs3i<#ZL^_Ln=zD8hozJIu?@@kx2|%N>+)p%qjc&VY(1ae` zBNQ}K1$o@(V!jv7ljw|0r4$*&T&`TabtgMVu9EI(u9j4 z>VSVknv8VnZx8Chx8zs+1U1!}fEC&34I&W_eaH_7n)cu8*A&yVNn?Vp#grq8_c=f} z&mrD-rs`nce5Tb5lxB8?0~rx!CmxTxB;Lme_*N2!J66w&-_OOpdXX=r$%1Yn27z+9 zph}3%L3lPhe+?D6p8=T0xG0*Osd{O#6i@VhKc_!|ei33*G#kELlaap{{h^b0tqfs~fc4r0j z?StEP6QI$yFe3U^-!J0o8~dW<6@PIg0ZfpB*iNf6u!-{+*-$2!9( zB`bAZA49^SWXFfdxFsxt{RKgUXJv*z$N zh=a$vvN?6R$`M!&3vc6K7lR!4O>*3M|$tCk6xxCldQDMJrf~o-hyL@!Z)9U60 z6Jff@bGtfY1pZ}f9IImoe5kT<;wH5W$vMSF7%{lS-l)OKITFO4OC}ENk>Vk}DDSu( zVwU`0hmeCn^CzA(fR9#EFik{SXJr{w?Y8d$x8#}*53*M?pu1VbiAdWKa&oW9TjiJQ zz!|bP`!`N0e$biFgR)~ZY=o7w#8FtZCuK?^qd#k(%tv?5Hd3Dkp5f0c8Fl&gzz4g} zC@Z2FhjtQ<&WFoCksL7{J^isr}oW*``)?{%ze zfW_R)2yn(lK0ZGW$tYlj_y>arss~Vx+2+OfW@Nn~L24Ts*e0PEL2&qbMtKm#IF$BG zK{{ihYu23s0o=L;i~T;=_&)#RMj;Myj(P`JK179@wxrPquDwB-|wiqlNkh{RA-aPRTe|hwHs9%!#xhjY) z2AYN2SMg&s!rbRY5%6D_zYZA}&VQ}N3Z-BB+{f(Q*h}HF27>`-W>)(mcp0HEg{@k@MRAs{a0l1trgllQ_%Et5#|vjUO>!K;S&W=i&r?c-?1oAaagJ zz6KVSHOXiu2w^gC1bCdV`=Jrcop2&tGemC)#O>; zX9u+)hYqYtf9;SqKL&&Qjtg0(TV}LxW1g*I=&Q>olyBe*H5w`fZzsS9Df-bj_6p!N zLK(idcE-yVc>K~izN}1Asw1!7SLXxa8K?KcXV7UnF4+RHbd`6(N0u>t3-m$STAm4$ zPoxltVAgU8Lp^&@`oP~k2HM2${mY6Re$kX3{wzCt<*3S}pLM7R{B@nqQ-z;tJmZeb z7!jeM`wXMA7anM^YKp{E=b^gCX`LV-SoLe{P9B6+z_=!YaD!$DA5N}@LCoo zk{Mu3rrrDN#5?+su4UJs_T^N2oIrG8KYu&oy{vf>85NqS13d`D(}$rN2QaV4;KyLPJ}--HTh+AjpB?gnhpGpt z0%|nNfXc1rNVJeJDkdu8Z5a$0xb0aC92H_^1{%0ig~&GHn2nxfebm=9HN6!n#*8&+ zwJ3@^n8sr@nZzQAyVah1OM?M$`&-f)l3goa@(*`n&cFZ8pEArKpiV&&d}!z&gTasT zUl)EIcX2pPxMi| z?_{OAx*U3@4u1P{Z2_dlWQHZ%A3952D^{f~D#N?Y)K)hoj0%AR? z;`3gB;bl>M33DoP$JW^dkq7h}e7!i-we;kO1XKv+=*T0@TXhlk(=> zVTc=W!K9(mF9~psk zH;M;PTXauv6IJ-0);a2G4ytnD&mgHmk7Q-#j3KtFfN7)tNhd$#nK06zsl`NBdqB zD`NmmjD@7M6A{`lQPhUeTWrM1{Umq(z2C43v$rxn2xV~W?G8Sx66k3cLgK<}-t=8O zH+fGI-i7pa2$$CGeJy^#THhQ0ZHh17XQh@%c?mtNVn;rY;3aY7MiU?K&4y zKTDU!hkQ9z2#^-$Qvln9AQg-MSd3!?j|o5_dKs5JWtjxgTw@I(5EcEc`3!2I`UMVH zIz?Cnrj7;bu6cr##Q`kI7bTSJ)xLUbr9VpH7?iM8l}^czZ4MvVJ->qh&u#Gyx~Riz zW!E`J`R{7qbfBd>tQ&FX>CwB%7?o8oY!=|;Qrmlv$Y5!alS;`)fE&yf-$l%d+$NH z0Bhs>mw-C*HWR>8cPtyRsZQ=mOfD75<8b9i{`Yaq7oeENFzoZqUSrauEQ6T``ALobF&FYmj;9PCQ z#s2u62VKLF*P{Hx3<%zh-_+pw{WDS)8GZky{IOOMNiI>gG5_q%tb!LU?EE*=K3n6k zp)ju|?$1}xnEU^w`uzSK3-qxXN%8Ib^ZBjo`}OB*E7I1>Ti=8z@D}W9*_@&HKmfn@ za{3MtW}j!vZ_Pq%h(K`0*^JqHb?fhuP;0u7l~pxOF2?c608>lE5sJZg2E$-u_xCD@ z?%19+C_ww0IgKXi?8-|-*?3yAs_*<97i~cCzUAcBLSVy;0e+LW?GWrT7wZo zj15P84~m;_QVbC&Yrs~pUWU5ULds4HHc@gIaUb9Vc{n5uQ_tfN^TSJf}j-thCV>Z z!<;d+!$yz_!fQm-O8}&I!WpcsOUOE<^tT`{nj$B~^>EtIyTIHb%(gpb=6$Rnx)k&z zoX9cdukE&)i}Ywewx^pm9C|=B_t|3v{(RQuZkKN~?{}#DGxvi-@R`BujAd%de)KCT ze~b%(%y#5_W)G$}g)OqQsCjmDs&Jnmn5nkn_W?hooR!isw^iIqYb_L%z7>176aa|# zUWopc=cMy8sypv1em>)Dz_$R1k@{7mH8M+BueYwN%=zHw-yeg8o!9^G|31cUHesI| z4q~`Q@t0!%%dULw$X@2-@+BMhX8Sn&IN zK~7`;e`EbwTUOvN1CyqiWHc{3aygHXW4d3h`OY7}{W(tOjUmYuv)2H&`dt`*anv4A zN-i=W(yJgt@YdDOq~-A!78NsSCLU8IbKBOIt=s@nCQO7%R~FZ_q=b zQIW(qg#p$`@kes)&`UDwUn+MTe9YAVerHJ2>ue9qi?H~5g3)^(l%if_9Ds!yr|(Ww zj2jRe>jSP74E+ZT@O3cc;kG|6C@2;SyH&`f0^Yf7vZBa<9kR`KJl+}qz^z;mw&FyA zA#Us?0Qxg|mjE!yMHq;r{T^|h5fCC?%0eYPb5S@;#EQApNZAYdoCEqwg_DC|DM9rn z-+)4NTYk@h%%`T*6P!maNSam$Py|mPO`%EU(QxXCpje%FCXGgh#y-+hR{*PXbShU( zPYG~z4~BtSwe+mR9RMS6wul$eRMXF3K!^uy0f%jjx$O|a$OUdnxdjo6!wC(0I2R`8 zyS=-wMd}^`sA1G{vJea-IRwSh8@_^F>HMKoEXQn7)_)RAO^|L5CiI{d$3Kx(QgqMN1L%7 z4nhMY3Ks=}!U=&r7q1C4#vLf^_=E&Iq;PLB?vAJeFSY+>_R9rJjDUu6!zorOxopB{ zCLF9(SPj+&CnN zzQ9;P+0j!X&ynLq@;b8Oqp^6JpHpc@19gXndRZd2m zjw%+rygLcXFdAocKqM||*%^VQy6j)lEp3IHj~_oGHPT7w;2;2Qd1@jtAFYK5bPNRv z;YfA}=zz_hUpNHb3QUoZC-iJ#Djm-hms|jk z<-#u?=gt7H93wg-H%UFa6L2%0fjbLzVnJ(0_62ZH#bQK+%ZJC?3IR?xDKXI{rL!~>Cs04q_m{deAyn?lP0IHQGBsvg08N6@^`CVNhNPBcr}v4-J)PG3vWZx!0H;fnA)bGt$65>+cMI+I_;PkJM6; zoWw|JNnp1osq}5JqAm_&Zt*2cO#~En1R;>X z*FuT}3W;)#9fj2}DaZuL8&Se!Q)m1=>tkNJ8DQmBY zFCKv~nUqyc`E&~XEeVGp-L7+>aI6oYSbt+A?~( z3D#lu%(jwoaYbC%DG>O@QNAX!jx9rIa15+t^tD1)-$WjPtcIcxg(E#lk>kLS$cP?+ z4ytc8a;=Q^xCHkukYg=^U08X`B04GkqnT*pHxLR&|L6=A6u!kdn7xFZ12A!56m#>2 zB7FNaQmLmxCMt2L{TG^l0|rng z$k}De5JUMc6P%oIQILnCG`9ibu1$TIi|fB@?&lZtfJ~q$E4+ZI<@MompZ!1>Nf{Lm zqXTEZ1;k-s927cgT)(a|Kt?}H09D%D>^R$6 z4fV>YJz4>hvXf*u-5{VBJHlAZbQvC6eP0=Qx-V9{`1dLT$5LjRCy6T z@rf8x5JaeLEtf_)><0ld*0(pk2k0m}lg>YHjO!6$CJR6hWY!hq_2({n!f+r_HZw3C zF>2ecG*Bu*Yi1COwCxU~EHBA!p^Hf%w`M?8X2;`ce^5mshyx;JV(%Acx!OsMUY2@0 zhqC+N3iKE!0(R^ycs#8HSw|cxE<;rh7tztEArn`&`Pk@C^#ZnbmgGjnc2QQ|cRCRn z1T>6DEaqm(2M56!x*T_IX%Y4aJh7Mv5CgCnHTnwr11ZSIH~^6Vz_1-32pbOk+;%Gf zXrJAJC7@T^?~>T1S_k?HiA2PfM2-S(OCrkU_F6qI8!I!~P!N{C#!dxCwTc4~%W`U5 zrePbRy~z2=J#>yq`Pl;zWI5JG)h052rFqXuPfA@xhXSK03s-Zr0!vVCw_-Kglxm_% zOWQ_Sx~!MB1Hu0L9GQ2a9JGb!R*pLyC=_llkjOizIr|ZSX=R6%q1Q)1<$BRFKTraG zt(i(mjXtp6>OWCjr|RYft*-$e2O^kNv@?~dTwmN0gqo${;PW&r1jDG`ai^=x(gdge}G(+ z!Yw#wBluwmT|$6*SOEFDOMl|QNjS7x=Y0ktKiCX#8}k^rV9V+;Whi!X>tN3j#YYl#{f za#EB)ENn!?xds<3+@7gK1I+hF9Mc75Wp6hrv==e@XmI-~eDWdZDE99M8_8h9SvQgc zNTg&Idnz0YJ0!O#`aa>Bkr)h*XTmu0(j6tBD|RD*G5y6}D(9zVHhQ3gC`V zv0Z7KErBAtddXuXv401*;ZUTEPS%~kZQrb3Qf)y3FAabe138*%ghT$okJzXrC&4sG zcE4`e^XE8 z1s&&Dv(N(ao*?A*0$@gzcsR(B0I7AX(`2O)^(KJwq}wRvd_Jfkq>|A`i~T0TDS%Ei zuXYuSv(27Ye2?B)AIjzKF95tKhMs}}#IaM59dS&TT1|+5vXSGwapIyksszm9DR%0R} zZCTrjIu(NV%d1Hm$!s{2cS-4@P(s88t(8cz8_Bsy+P;{h|GKqFOXY)rqV5QU-%m06#^<(<>=ywxUqtM?vlwgz}Dq zJlMh&8PXFL>nK1M%U;Nolg=3Q(mSx4PiPP^)KJ9KN-qnOTDXnE6+}O0f3uRcz`q~B z-_Q37#KD@BFoy@Y_#;oIi$|wZRJ%tWI-_$$P!VP{D;BVTh(E{IfywJIU{B2lt?|h2 zTk-AI9k=L6(dlr+7zs@;TO0yzcY+jOWk)2o%AJEzWy#)&u__ADo{)^I!xX`VsMD}8 zB2u{PvgA_YYy#t4z*-nmQa60l@QVOop(<`Cpekl?G{BUC0g6gDV*}uK35uFl{~!kJ zLK~wk@ktn!x6P%Py|OzjMOD2N0D)r=xZ9TF2$K+|f|PqSMY7RZ8Gh7}w{ zqpql+Z4!D9;u)SOR8PeNK$$fWfw84jWo1R}IR+M98ZwhEgRcM9q?7KwOQ+e7wP7t{ zDQzqTmhJl5)J#{AL7yfPXfYlD5GVhPga@2^U6hR@gObI!F$x=duUAUHV}2cA!XT%V zCnc{fX!3^M26d9d*aENy@U{j*A?RhOP_gV(U#fzRh)x<^LsI*+sfe=!;@f~A=aL3N zY=^)^0Fq0?C&C+%6z+v7e8~6+NKkde4gfTdOY=k{MHY=gwECB0?yLwz7NS@c#m+D& zFb>#>nKYyzfZcRz6Y5t@qiLNd7tmjzMGt89{KpZpGc?(P+f3ghrD$J74Gc*l5XMyY zQi^iXF|}1tRHq}u+`P?G|v`j<*eN3Wu z6cb^hT9tC4_LEwm2E|MhUb5Ey0Yw2Qh%*O*vi0q4GDjD=aS^~QiTFbUPGF9NM)@Rs z92!64zd{X$N+O6zCx{b`@cP&uBKi~+1ONaa07*naR6_;A5|9Y7vRxEwXokFqHc)4v zvJg6Bbp$tv4x=FhIw_^$P!J#pP8Ujl(8t(M-#}a|xfEbW6iWa)7%W9}Y`&2#Lk`wlb1q6`<(Jdr47amgaWI7Q0lzy* z1xr!vC=81Z=5KHSu<~u6K>!T4g8(Dtw#{IJM`gm`Y?2==@Gc4$L@^^2)i^mA^E((< z5V+rT2np6MUYO{iWuOxgRbz-;aa?SJBLittBsWliR?*{!0(AX~DN%F4FEv>jC`FgF zj6wIi4v)+tg4qK44$NU1otD={6u2$Xl21~QfDcBUA0lZjb@3cT0%1}d;@`@zBrQhj zD**9BV)l(nEMHeao%!n&7lPb=6z48XHD%4wu>Z6+XB2kRlA`R&=G^n?BWwCrIW2eo*G{i5x8$ir6eHYR`fXl@dm%iPHa(vje z@m$Z2nMQ#Qex_r9-TMfL4q|{yk>>yuXJ811b;VJD5xGm|*l9U>KL$X@BvA_y?AUQE zJI+BFA-4KR-8rek{e6zV`(TOv3CV>JI{`d6#PLic^8h|26)m$ONc2ApA>T953?U0* z9?HcDwNa1w3aIT|7dZ39R-sw>a|Lwo8}$b>^)&z_ZB<)^T0r3(3d~Q^a=uxoC8Vmi zAOS*=6yTa6-`dwHWwc8}Xf&z_t5opD;!qj}JLlz=nN1+20Z#Hgl|yg?uynal8(!B_ zH*^8$n&!SlTmr;pBFXJ!d=6}`=dfh-&v9o1To<8=K}D7T1Y%bzi0DGoIjI=&j{62G zQVc{qz%E}zK0_2s)pDHMW@QEFj*h0VuN0doKeenp%s!7iykF_GuD@)Jep zYC~x5CD|luXpNdHS3pvJ<|JvUggBlwwhmD~HrzlTq&(C`-hXeXOIt+0DAtBOFhgJc zz7Rr1j=D$AHHb`{y^bRMlu&?s1JhhMxhgBEm_wY_tT06grC#Wzxy4wpVJ)g|1vF68^ zbr?}>41k4*jAo!wL<_rt7y|Q+?hRK_r~ElnD|Z|+0NCNV>)8J%jCZ3b?Z1Kd17ir; zX#w4U0S>T^MEu@Y#}rZ;7)ecdz$@DQeglJ@g{mweewC@Xv(SPBa`s{y6YvI&VFe1`^phR9 z*evWmdqiMT2`wyHG>+=1wqNQIN%_kJkt1*-?FFKy$jPs12j>V_2y=*GD4}Q&D+Eok zJzxeo;*x6`S@Z1;{H@gAC0PgH`vm-LLv7-E*iP&}(J?e%*SuFQ!k#%H4t1407Y-|; z)7XZ<7aPc0kbOtx*R_Yf{TXAX#xxvCJ4qh+iS?xk`~ozpr=IGg{PF;i^Sx1i`V>T5 zRvP6}(TCQ{5yzf-0bW7IG(-~AsYA0K9r8di>}YG+0RU3;9@@1w0XkFLvm$o}s?u+6JSrE#Mzn|~5sOK$^fW{^ zKl50BkU%AQxrrr}W{?~PLs?iw5GoZ#()D;W7|B7ImmgpuejAb-5pldtvFV0D5bSs* z<{ZRr5C(y+xHYSmCeYpxMBiaJA;%`5tU3ZwXxs(7CY;HJlGcC!cZfA^^4J0fD$XP}dHVvJcU?xF*oGE)Q-zETF7xsqYY1v&pMX|mnV**$# z=tAJvwIiu)786^A9SM&%NG(e1RC`<7J^Tc3L~H`)d{4NXs(rznC1M~W#T?EG#7?A; zr~ahr3%3HP6ikjnf?J5oPbh|Jy(cj9inos6WEPE z%(DZ)oQfD{qNB#G&pU-63LtK)FA4q*j0HrAF4BiKfU;eT#%`38n8pp@=B%jiS0XjP zgw$eU=$)D>+$z?OeSeS4g^mghUL*4tVCCDI z`T}rf$_@(ZNT7l$If-DiwbW4CSC&c9^=7DCCz3j9d7recmXAab(OyYE0<`Hb1UuFd zS@c>bfN%m@Z9c}#)-aiZ)=ZggONC#N|IvySvAdzP3d}8C(>tg@MOSp;q{X)ZjLPqn zhN{30Bn;W8opK7WB1gnFkn-L>xT4R@y`qCAxCfAO27-bI=E&ajl{L{wh%7r7mqY<@ zZ0p`e(oDK#GNbPKfqy*)t^?La`Sv42^{OXTRDNW z2rNM2zcpyhj~2OS`}|c%MGF!r?G+$TqiAvme6Dl}>Y@g}d0Jk3rbr}2 zab(9$j2GT_YlF&4C1tD_BMH~vJAipzUKyvb7;@JjM%-8$w^DN6DS-&a%{%YGRF50iItt})XFd)`Nb4`KNivn^D?e5_lfLdg^yR?DMrIJtR)GC! z0RBmsMFc*vYm!$MTYz>4p#UK51o~XH^jbZqqf-A0lmHqLp((JC)2jSqE!E<@?|P2} zSby0CPsJtEXaXGbgigTIH6o0*X;zx7zMuu}&;e+ULErzjUcti8H3;f%fzseH6ph@T z9K^*4HB(}^LUAX@NFb_acSymx8Cc<eAP2UU)u@jnuYH|R^_M3hn z0LwV`j-t|85htjiN>28oMydbn5+k+(JT&EKrqg~XOH z%^;$=Hdc%73e4H&LcNjGOduq%N14(g2t}(isO?fN(Zq($QVVuHxrXMj5?mb7EPqg7 z$EkbT2>>eY3$U4xM4F4ixPZ~^1a>H2t5SCNuGwwnR!#vn@n-ULh$-fVLcJx^$1KEj z7Ug~dG+~fu1_pXi&khMR zn)VKnRKS^SKrVs=@&a%v-?tb!>#yr%ii9TBmEQ`)HSI*yVE=l6F5@s}R9XyzC;8g( zpg#dbI(s0;Ku(}40PE>PMFKyO1C5B{y`*2kPcxRWr1c^%XIN!uZ?T*z(-FXr=eCsb)EGI?*Hh zK2;7R%4MFCckqWWO)kiSX$AcOAseF=Sq{-qI!2u`J#54>2c#~7Hy|%u6rh(Pg*i(l z^UX0Z@5AO!Y=GV9yc3k&X0Eu;MWB{JO3TfRmG= z16_Z`-~nbHSo8x5GYrs zmD$Q>(aZ#hXOTyjuW}sH7cf&FmnkWCeJ_k=Bdd2^3ZxL0f1X*r4b01T18@iULgiyn zB`G`RGw#~a&x;d!YPtrZg-^AWmGoU(TK@H0^tpoZw{MG=v7dV-cO-K>hj-h)_Lh13s zI?dxw)E#Y3ZG~9@rYS$;gUafO1y=7%*ahV8^cg5?26-9UV;H$LaGR5fdEzO7|5ygzs6CvuUnDS9dHYCp?T9$9F^8YoL028aN!EX9k@B} z5GTAAg<>WZ4F{ZYxQfsG3qv zG#o(}H^)Knz3HFiyi)ny@r+F={aJ+IfPpv+ln0gaSVVFdB&ZVKfE>etYu+2gGJD0O z$^8!G>IlHo)-6A77uMU1Mbw_1vso!H3}SA<{b;Rg+{WfKUd6U)>g$MD3=$RU&OkeM z3YG2l;cA>Ca}tWiUVVbot-`hyimCH*kw&S8quL?ynt7zD=ELfL)#q! zdv*<&7HY1obzlHDh)mk;K;{XzW135yoB++0$z2%t;=2Ih(u>CcwVl)4e#^%5I_HF2 z4zh#jK%ziSfLlvMZMjlX+2`NFX1_r5K(3(R6Uk(NV1~zwpuM5c9$|tf&J}P!oq%7% z9+0*lBv3@xEV5EKkdfZL3H>nbN%fly(&cMQYl+j8V;~6hWk*`=(st!=|DGH}0=sl$ zBT*oaG^=n|A$v@VpJx_(_>?i@bvOPDz?mft1+ zP|`Q~g-7Yzxc-?2ca~qclcR|hA0s2KWT2LQX-5aJ#Z(g-LU9F9W!k?tSMC@E0R4WRoiBfhpEAh!oG3&2wU*#rQ6 z-u(djY=P&P|G^3s$!`B?b8+nJ3@hE2*)NSf>y!SaH6}inVV-MO)-ru=5^0;hp7XEq z%o6_oJRLcN@P(+Y9e|kyYlEbC8P}*9_Z8Aim?8~D1CJn=6|@SZPh4oDNnuu=y7$>OD(XuJCea0_=E)y%mrJ#S1~@gIunfq3bQ~Z)CrSE=Z!$F_dD~ zt?6x6&s^9Nu*N&@#PUNoWBJKXV)fCFV)dT)VEovfp_kFsh~W`eV{z;l77u+W78hKA z1J@nHaK#ndRj^HHNZX##XHis=Lj&$4ESu`Ca@;(q&*a&VYOks7^$l}r*KrE5yD(m3OHn#qhT!0QmMBy!H z(hnu@Bf-x9xI-rcr57|XDC9q2iZ}7wg{no80Kj6$b{huUjtCSvh+Rb{C_4kb>1Zxc zC3FP>*zHASw1GxJ$O@%nb`u zsd@l#=%wF{^Plx)xZoc?0l47&EgH6EklDxei99G~l!6-y2_3KW3HL@l`7K1_r!>}U+*1^6RAP<9YA4=*4 z;Lm=6pX@Af`Y-#f z?mGb8J%fv%_RoL=i}fpD&B`1q_zxYKs^ti{aY1kIQE4lYge+V2rxGxSsmrGpm_{U?o{IbY2%{v2i@vm%E z-yH02Ox!)rw_pQM>+Iv~vz9*!;vf0Qop{q9yafji9442@K{eRMn`x z!YKC&2yenf8cQ|Z+EPu+zpKE~k;1MjmTGakfc>gsIoj{a<3`J?b`HU91m63Gx9yq@ zja{-g48S0R0kRk(z#teF?)i7IfD8*6WPo_wuvplc0TA#_YM~jEI5l@4o-&+w2zP(| zSIX{x`@_k^Y`XwH9bp7;-TkM4^A2r|0QBTZ-1)z}2KRjL_t1gck{rEeLI zQ0%H*G_tF9>TjvQ8Ak|9*yW*y5DZ}~{dc9nN=@Upa<4)=0FU;X!Gc{mK<2(Bk$#@r zwhRWi8%Wwfk?IEXQ}+G~YtFI`U!;1)!vz-}wwlC*1X>PajeJ-qCVuOruff>sX0wSd ztdVvf1;BXX{nG;a!b-UJkKT+sp7vB@M2R}G+J!d9g&LP)k&#aXIC2#P!0B5) zf}5{Bf{S1EQ@H#kFWTJR4MDxV+q6po=!y43JH)nui+(^uT?YWp8TeUd{cYxeOroNL zka9B6e}_Q;T#lp1FN$<0ZoH~&@1-}OUeiIF8*?0LK<}j0&jUyp_=G}GDGF$m5%nlA zh{}l6lT&I2Vkf}ymqS3hzzUZh%uv|t1WDm=Ih)>S)r)DMio~G>vsBW+c%$a=#`r$- zw~D+i`?MeXDiNEU6|aNEE6e%$u7rvler4?X(GD1&9!`d2_WnH<&S zyDn|hXKmv~Hy>RAz(Xzsjy@81f5(gQK^dU;-M5ccTZsP8w1M?-Ja|Z@M{09n9xl%g zTBUn=Ab@_a`kE@%a2b8nx@Pq|2UOS@*mPTdrT&aJmZ2>YLVR7i{0dm8C2M)}F zAqadS+5jY!6Q)2nyWP<1yV}#X1eIJuQszJ))*53jjFkx=>1Z)0kPE8Ez#_D^YPo=h zBhYq1l4|zx&G)W8)>-S>xX4S!4?XvJIQ{FtiopTDFMsyAVi#TC|Gv%q9!FO>Hb)QwO zqNE}4&jI_`J)_Tyq=^i#wo+vK(gUYZo?~dx}eH? zKj1yUuM0glu@0c?w{6OWIE%fmQUEu<l zy%0x_U)JP1di-j9)+3&Uc9ieHD}VmqFqi(K13*ha9)eQ9_y7Bzs+!E9Dq*v4EP1)m zW0)ESH3KdPA1lTI)TK_YqPbkE`8FTsrgt+4t~;f+S3PSN`RUX6;8VT?i)*j*G+Qx` z+2)h?WA)KnaS#Xb(BFI`4j+Fcb}qXNI~P3!|MuH)*N?vv_x|4RV1a|c z(SDn6kNS>Yk6T~xd|dvFFU8{0OV{-T01?2PFxJbKZA7!^gpM!4ulu@HoF z1K!YZH|~x0vjgVnZxFjP)bK!_+Yj$20@EK#dPE5!$V-k>gkk1IJwb{Kq^2X^QT?0G zT~IMU#6%P=+otiGac$`9P$W{Q2q@$1eH{Y0`A1%1(&H`sW!oSAf-B zcj1m7`nOnId!46Ij$HJq_NTUCdEzEK{8g{UBVO|2J$jqrz*Se__)otEw|)NS2?vrZ+(WjAbd1lZjBEQ(zOY zI&l+@y!MxH{akycO^M&>L{*yn|S|Vs5313obk#t5pa19WuzaXaG9dd}y9Rgc9boK?orV zKt)BALX^_Gp>hVu^L9oJrg#kJQ@xaz=^nv0(lczNO`eAXYo1sDEcmaB;s<}|tbw25suw&Tcm4h! z;J)AbeaMwpOzH_^%7P3Pz^RXa92aaTmj+2p59GM^Ws^vp3kR@gcrYOP9KsxtQUI4- zayXuu?y^e{!%0U8r|__cU4ny$7HRwjVC8lIb=|TY{)$)7SPeRnCL)Nrae&vcD@Oo@ z-6H4+z~rcISC4`S}OE;d&!u|b3(mn8Zz3nHi^77w{3fIbm`Z#6DlU(g`}Q+A3j z&OabzQ#s&VjMcaZNXkzu!Np~lVdvqOP4~>^p65cD-Ba(s38w+<9NcMuzP6(ugz?N0 z0^s@|`oTRa+AbXXcR!4Gz2P^ogDV;>08K=x@Tnrg{dawQW0}>8vKdFlX&DAh&f@?SRBUXy&A`smH zR8$5#A`k{Z**)3}@)N1O`l){biaT)b@Jq7dF(&G&2J4!RquCbFN{1HCL}LS>qZ=U& zRLzMQy^hmGHyJ=F@7@-Zg+{ZuT4}X+%7@;*a|1%S`zAXZFqV328~QK;Sl#*|-0+|O z^ZG*f87{ctH~?UE?|pXPa}|4}a3B7>0&x1K4{R*6K>&q&=#c@Ci~d1F05ToR#bPm= zG`{a_{(i1uZf(+#_);%`^^odtaxM=?~ev14}1F4_f^kc;nJ^r0Z#tepF_?+uXUX2klIk| z3l2l?_~^#+s?}qE@{RqRV}TzG5kLUE?N8nX)zJu!2=2Q3J^<^q{D<+UfBH^*^p4vx z&aq)#SdAl&Kk`~!d+pVGrUii0rwX*Oop)%`-`(9M{?Qja7LWey>(g*;Q~-D4 z#En(xx-|x?DTBa@7WidmO{peDA0;Q5AmgldS5Mgy@JmE@!iKlM%S+u=cLjHeoBkSj ztXQX<3oLR2+H&|gVl*xZ0fovTv4v4;vs0$|0Z^?2bN@51b7&W=;DM)7_x#3xfd8CX65cAg=t0S3bb406zP*zxaTbw*s)^qt=vT3x5>A!)Ly}1-F0nM7$}0 zhhA|pe(o245C;w(h`3XyPGjeQaVh;et$}McOoJu4S(@iV1rsTQ>EXM0+{Rb481(IT z6;X2vs&MB$UW+tW*`UB85m(_O-iW9 zq@o^miZn$y>(}G{s6POlJn=rrBaRkOX-mp?YsU(}rT_Q~_Fd1XI+%Esz+deq=K4qk zF&Wzjj3u}f*Bo17XJ@3wpsl* zwA845%6Il}684W%AK3g`hb!=wxBmH_sXlqn{dnCk|KN!0|ZV-SC&*1<4K zC&KIObKs;9(zEoW)WS?`OL>(Q0&=dZSB z=8=GN=<=yi$Y)w;`+}MNC6`cs$GHm8+I-iR{g;H7tc3h2e5)||WD!;K2qebniRL)~n)?yv z?GjP1Mia|O#Tt32ptcDvs+fhaUs4jc5fzG0`vGzrEh3>m;lv@6U4Y4N%f8O;Rre#j zWeE8U?G{;WL;pMi7?;)h>oYSnO2867?O*VU^J9CiI)Ip)CGNd{i9;?o4+C)M;CfB} zi5u_5bnhJi4!U^Ih66hTfV=Ql?|d&F|9OvETkcQ1>UVJQ!>9Rg`QUx{wO{|%w&|tc z%`i-%IHK3-ESu<%mb)!Kud~^q5&MUy0!NPvMI*uz5Pr+A6|7>r08%)BI{TCcQ~_Be z(?aCH{1i?c{vY?woYZkm&38p=IbqZ z`qQ7>#L)I+9S)pZGt3i|e=ajQh&9M!%1vZKDg;wR&~yu~W4X;ojp9NEgjsdr#jBiR zE&}=P)q4WX7!k(?(_I30Zsf>7G@Xf7WZ^M>nlZJbVO`YsJev`Lc_t$^_dNGL^LccO zo5^Gq5r|5h5x{+S-igDQ9{TxA3bX`lc18QX6@1CQJs3d&{ON!FktXgF_ng8d4|zI{ z9zQaZ;~sqR7d-(-jy!bo<;hR}e0=ja{U@B+9dYnLt5Cf5k>}%O-~BWA&hPj-EEdy= z|BJtPCxEH`=0mri#y5P^vvJv_hhx1})}7DMpCSTOE|T23%%q(I`lf~5S`Qn7pqN3% zZBBz_bw|GOIUzH#N9~CWplxxjyhdarGg6#`4|`)TH!lWp;O{p)tCq8l9Fj^AW3Dv) z0rI)}uM3W0A8h_!?CW{B7~`2U))wvR()8-ODqXtso$uU713o<=#;)%ZO5dDXMIUi) z-2SGZ z(TwtM-7?!G6_q16ZF!Qv87IxS1hVM~pBiCq;JK8&Q|UBK0;nz&7vObN$c~o|apr&* zeUaSLvW|3G@|*o{(g<}671=m8@`DD*auH-EOR~^l&aT6OU9}zbpi%RP1YtuBAfGysQAl*Xy??nmt6<_{z051pdn8~+;mmbE?|HAL#pFH^qP5h1TzXia} zICQY}!BCZd6HdLO)^j?N3b-|n+(6|L>h~yrW_$@cWB*a$cj^!c7)jj;O~KIa=UWX! z+!*eKBn$5>Mz9bA6@VI?0GtGU?YHQzVTc3L;$PMz>+^~5&9S_+~OY;5n!M5u}v5T;6MJ@cjM(R`%N4@ z-X4j%>hgp5<=6feUj3@?!X+1{vhN-5_*(!|!=qmLHwWPSU56+eE?=j4bdrdV#xaxP zSAu9J?~+TplY4C|&@2IIaijDz7UbHiVI>amDs(vBH6|O_@-a#2S#AO*n>;H&)8Dkz z#ivkbl;ZXj;f2$9K&$r7{vehj3dV|Tzg%_1HeW9^vRowEbYzhQ}%BKO}Fi^8L1q@ z0~*#)xpNI){^d^xaQm9CSK$x;@a;{^S3Ub@arLn|!=hX8&z?RV%hob>8hUuHz#e&I z+Wj6x#_nVDU92H=++zy;iebb_JQTd1xQ5#Jfa~B8G!7ld#(eOLBycY#Y$d-8jGCs&(_;>-NL$9)>u>2)T;c32>I|Td+ILIRFGg zXuDCi7zo|eAq?44m2iF-CPF|!q`UqnUWpsO>!ny6ITrO)NouZ$ z;m9%k^|yZ;E_}o_xb928bc;sJhxfepmvGaMy#j}h9NW_tfo)g$2|ABSUixIp91*)?|Tk@>;HTyuDIl3 zP5gO>7QiPzfcM?_Vcc;15qQ&^-iA5$U)NavG4yj$0Y2V6(Huw6)YX^M{I<%#$m8(D zOGZ?A>z0AnsCGjTm4MAHUL^K$#Dy7!lC+&UQa2+EncdTinC`&x%wzFf1U%1NjSKv4 zQ$gAibWk;i0QL%v2ry~W*^!#he(rPdH{bQr#=*uMu=JC9@W?T|?HSL&?ytNKk9yu$ z@4Mc={IMU$yI=mY0RC;lIGW$0!`B_#n9h)8mH(&6pz2-(fE+o7oBqxB%r4#@@aM*6 zUHoYPpZ%}?HFhpIe|rXP-6nYnPkiEI06YY{%W*>8fy?mwfAHUM!;8Ke-~C;`gd^9t zTZ&crSHmpO_f1=qhLnwKl=0OrfO-13u=kHw*GLHrHsc7cmRqGnME2jnPf<@Gg@fEMipS?YCi)|^^_mQXR0b1=Ux zXW(hizw$~Pd+AH@kypMlb^}slu|5b43H;gDJP$X&^{x2AANT=WaP?JtR(bc0H{!oM z`iVHXdp8bl3H)r&c*}=y-4Fk}J=0UR@5irwuZ~=O$c`LqhHeh1`w-N=yraAiH-9j_ z&uld}D_nT^f~}Ij{te%PXMg3ZarAh5wD`#N=i@tG{02PbpMD{Lax~sURsJ=WcmB^D z=QKy9o3v77`i?UI5X!zp1iVPp4EN%@QByy@U*^y=(`JKn96EryeHXAxmLP(Z#tEQf zqzzf$5djr#CP{eS%#y#AW2@%vAGI&OONoAHVF-H6@0?gDntVE693are95jraf2 zAK`bO@CA6|4L9KaiwG z)aPpEQtz>B()fdV%?5jUK0Zu@BSM;O+_5X)IRJ|E@GHFC-FNApSqVBwt6wZg9b)ZG zVb={2iE|9u5W4vy;!y<#>I*tqfn^Y>MMVXRWy1Lpud0U;YtluK%`-R`6gYJG<#@u+ z{XE|Bjo*NS9Tn8_TNevYLjwSg9K#_6?)#%sl(P%_2@*b4zxw-4`(u=Wk>BaHAdKlog4s9BGAjpCf(%e=jibZ@vYzTi+Jz*K7waG z^U1jQ;!80Mg8T13jf*ZigwJ}^bxrzbKI5tQo|pYRfNLjZ&G{dw$9|huBzbCn*TwbU zdZbHA4YV?U%~t`rGy_0#;zUVw5k6CPC+pvC z{=xVGq|BiC%e2i%6^Pchm&O6uo$0yNrM_RSSgE=_fHv2RH>1>qJs^A&3G#3M{D1oe z3;>oN{dlu?PI+46KVsx;qg@Zq_x2pVdjG5}i6hAB=@n`w(4N-Jb$vWmOPsvx4t&L( zcW&-q3F%A0{{$FCn#5&)$iy_^Q`41fViup?-E_TXMTf|J|C@Y4M<7tIXC&YQtklQH&?e44pU~Q{7rM< zfN=NGEkK;MioBkzVxRbK9|MO>(m*?AK-Fuvm5@5Z6a zw)aEOA|7Wql|U|O2&9F&ppLOLZR*E=PInP~7MY}*3qSITZ^wt;e{yXfjy~cLjvl`h zM~^=YM~^=oz%|%8;C2e7UI2LYk3AnBzxD1t$K{p}-iKfP^=}54cV?aF%u>9v9p}lPA&R}z z&GdO@&gN$2dC&V-06wswO;!inhg*l#cMPxL=M@S_--l1&$ctW#7u4B-YmG z)PGdiiB547^$;OV1Yo&#IjGla?!dF5VHV(;8G%^I3y1c>DmB}{qD^y7BE1T`Zjt&x zX**7%67yWbok{Zzu*CtltwLjKz&7?F5#5?Xwx~!m-Q&LIdAR;5UxK&%=qvEPSHBA9 z0a#pf?dCepGK{z1i8H(R;xK@J@#lYmYo7S|4|pBt8bl^Kp|cL70$=r&PsJ5)ehr@X zl$Qdy0Kh>22Y>?uF1jS0h1~PeC4i6N^qDy?K^C6&)Gq|^wK#F(V=+I`H{sh~^zQ*A z5ZBNnf(qb?tmnT~9aZIcs)$8S7=E}7ume2w$3gu%3Bn4%QWc{LR-;B4E2R?9;fMSa>yN-<;SE5m?JtcJQg<$JZSaEB*Vgza0YB8j>L-*mIg=O!04D*+hfmB* z)cSBgU@p1(*@{gDnZ!3?F0K#V_rq6Tjeqf~pTrlwKPk!R#c+Y!o!L7I5jrZPo8x{+}?(T>uKH(Ui@Pr%id7t~3rVRd$ z04}}cLcH^D-ip(wPB#dedadPhh0l5P4Y~bR!YsA#fS*W%fY{qgWV&=@n;b#*+TNup zSK)x}JxaM>Qlw&?4*bk7@B59n?qV41#E?K_a4#Xl;MaE90v&c1kR5@@0vHAvh5<4R z;4c<}VPV&c3Ea1!*a(V{k`GXbt$7*3e}F7}_hA_F9>8IUtwjh#hJoWNvJi}?PT3V= zf>IZsQ`$(b#4yNk{`oDZg7&$%9h|0_d!Gv%(u}819=*s4C>(!3I(G^$oJ!v;ak@-mJA zgP4mh83qgs0{b9?43J@AK=1eZ$%1(uftbY*47EUI7(!K%_x_2M&w6BVz_wX!(iwEv zQW@NJ7*9DZhyf6Y1wg*f0ruG-fOTOT}4p#r3%hC zQaG(@0I$FqH6W);@)dw3M0cJ2-&M5>VAO%AUIT7deCNP4qYQ~W?(3^bs6zK0AT+w| zuTMG6g)7h@fiIMUAxe~U1ilk#cl;UhP=Q~A)$T|%6WB;6Qa-e^oF*@rrr8uXTj?HY z0Gxm2!|{kKKg~`;*YLFaq2}v~%18~MCmS1laudX(Y5Sd?sai0cpXa^XJ*jPD+iRKY z5CAEDP1kd5&DI`LBw#CO;a6qmJnEZol>-c!9*!7BNiG3I9xcMDJ|0wQ%WDZW0mZlG z6s~9}-Ho2G89{QRcSH>EQhkpYj0r}Cp<3B}6p&c)KH-BOeDP)Osso z@Mc7}rr)xiiFs$!Y>NBuEo|t+w%wc6>aRhx>&o03PnLwLTIOjzV(9kNv_v7l8jvLl%2L+~(%af7Q9qK7Z@*=eB@90o*_WbuBpWFf(mE@b~ds zeXlUlrD^KZ&ian|m^P4xHaiMgmP9|&l)Pi@N87Dijx9n6kIkM(3y7@z_+IVHsA>oC zX3-;MeCpmvnQ4?qxKujqbh&b3h+_T;fVDD$eKbYa1y3~G6K-|7l>xMvQ(#!$oYu`;LXjrrE7jl*%=t&|BCL?x zhyxYp;2lDM>Fh>zr5f_sDgrK5LkHj;ESR|J)grFW*vJXV$qN0BGLKJ+Ppa!CM0p;$ zqSOl9UQa0d_+FmDg;6^-_uOS!yK?sn)SnJSutl9`1N`ffB*<+jzeDAln%ElPpp6u<*nei|_5Vbr|JXOI?K604f&kwn4O5+o}$B2lf9?XFPx>QZSPgx!b&yLdA&;aU(IfSzwdbWG&bWD(7~j2xPuV8 zP(Yru6zTu~AOJ~3K~$*lx5Uo+bN_&ByeJw!Ap_CC2*{ubeDy?=`qtYBvx-S|VeBZF zA%SSTheAS{^ijTQBW7TNWRP6P9Aap`mk(SaJp$Qc%WrL%LrCWu_JLUT4g6~|tm}q$ z2_|?o@pin|JatNeHI=Ms+KBmS=F{A>meeElTqR?$|KMh2wqKEmBenT%$U}sq1&C4i z|H;ZrAd?ojTdrVPzC}83C!a$ksO@v)HLBXpK%kMn@Ni&mr_mlD?+rlQ2yB2j!C0KNfPP%NO74=pi8F9180^-H`5)a z3;YC3#eq*H5@6Z&_Z*9vkC-Xkqk2GTQfukDu1PbOzk@(6NUq08>wtCccTE=>odTz6 zL|gVO@E73DKTBDScN!(379D|B*}VNuicBD{VnWShr}65#qVXEdej}v{5jy8+eGP43&-+&%8PehJ$DVFcgRa33i9K1L;`3VWy<% zF@O$7?FEI>8`^c06YoqHRfkY1n}M|=f%4vepUsCg-#*jA9PsyuuzMsfoddRBY1ib9 ze~S0%k|)acWLk2Muz4Tn+E#G#9FnNtYN%$lPIGD#qg{0ikiTQ;X9fHK^b#L(dIfMF zzg1UO%P|zT9%UO)(|HQTgvvdrj$#VHEXNRu!7p_YJKoviWhgy=Kmi?W6o!F!0NCdt z00^Lyi}d-SuJm&yA}7Z^7f3Z7?pXL{ZbLb!@XJe;2Mt^ki8w~gP>ys>V9=p|{=`7G zVUn@7ZH|LjhXg(Y!yK%yi>NI1T=EI!_IMFdZaqahpec-swgh}4g@PQKe_2)%j;_F1 zPHfjYL{yOu^;-m7rq_~DYaARIXjKN5G@9=ffE!>YfNSPso>li=<@%MkcQumrFTk%8 z1)=8lqHzh~gAss^f)S&M0E8}!4SRk;Ng|>}lOP(h zx)Eu2YFs9z1uBIJDOSg@u<~nKh4oe(X^Gu$Hp8~F63%+z0($Dw1NI!((97k0vunCf zW7rJ*8`73{o%zsov0VP*;MyUA9<&90EYU-guZ8uQmeDJYy;bkY6{pAam+bv^A%82- z_sF3Fztz_Pv$nw8v+@-|Fdq>^D)ExqQxUjSng+K!1F<6j(GjC?cY)^%dW6F=C<4$Y zhmNC4+=SARfRORklzuuGzzQj<77?5LA&Gt2scVXkW8MjMq2aF!gxiQBDxy-i6+_oh zF#%6A)-H5%W`ktb0q`WO;Q+P?Ti5a6gl6Gy2)gEZE^`|tN$gd_VYG(8H^r}YLbQ!( z(pMyq%d%+XrEQ7&S8}am;rps#Pm^AA+4cb6{CQi#ZFl)rfx7qfQ}^P73+TS}&nrbJ z76aG|a_Eu8s46SWX#LB%0CNap9KlWw6Ldhx+Igm)@;)5L+N=t6R2>Jw0?=^<47?dO zScu4n7t(qIfIxEm6SdE^U5lTfJ)l>4ss^-b)7)l^S{Dgm9%p_ z@+Kg!b$qIjfi^!I6KzT}2Q;&Hw;8PqkI()qM3gO@1}X+12C>c705p<=47i42KRgU9yd4G_qU8NL^+~@;16mFrA+N8z?MB>f@0d3)- zZZ8T+iBFf*dv%h9tPTeam-rO-fXU`Wx>B>ON$bxOhavU*rntGV4Z=9r&;iD}L;+xH z#F!qqCem5acCVWeoi*m$eV~c;_7?%%XX||QLviUyj3w+ zkxkEPb3yUeAzzfB)tz){d+mJF%$9MC7mad#KD``)Btj^F-~4s)Rr9}2`BxD`rOb1P zqQ1AM`;y`(`!BjwKNvJ>>?-gq!U%-N((1l|#TbAEZM9ETFBp z5jEB!0bmv>Y@(c#y5_>{>Yd3~%d2hNXVDF)_6fRvO+M@VH;uth;AqkbkpKX?r7i{V zF#$T;*jnW0pj67I!7Ig%7{N4@R~I)fdd@n zjexjH)6$kK-mP+;`f(Gy2X$!;VPD4a{I z?W&+;U>y_7LQDX07T&i7_Pv6xd7ewN0r=N{a$s7MfQx&AdvqpU`Buq&zYUjZqVT*D z0wyN1(wZj#QnF0(#U4t->c_^{8IPZ9o^5MbdPv_U_NgcpRpZy%A!Yk7;|R&8F0~5p94BnWFmHoaVX~o z0z*{9(5+Z@u%o6uqM1Vig`1qG(wivvgAg|0foaNpAdSE?nlU!)1ECh^)o;EiiWxvI zbSKHg6DbmC$(!j*anTL}VQer`JSCNm`Yq2G{chdRt=;Q{*>t4|EhdISHut>mGr1IVSK2hoQMeNT83i1zJxuDpr8*u;T<&9fmZ| z&#^1ilo9r8#yAQ1GQjuYt$3FDq!0zWF1*Wet+F0R($mx3rjQ{b2`gO-s~>zHpC z%=NAgLMMU(%H3-Fi5PhlM47hO8lQF zLJ7c5IfD{Q#5cs=SAy3dY)8oaJaroNNi70n>CcFKu0ZUVDG?2TL>I0Ncg#geq>2lb z;@3Du9ssJ;+ko23Rrd>^$vw~}K~+p{FNgp@Sn3(I7vDWWS!0xQ_1AJz3Y?-8h$fg!S}CIT8ud(_N%cY@4OlRxmNA7VlAjD1eA z0YBIO;G;v?UuX-kfN=sc1g;QqF@O_fxPv&~rczzlAn-tfl~1D~0(Hs)Q#h09w~9!7 zpyp)$Og2+^MFRC(2Ydk>U_Z)-E|j&sH_)$3ut!FnJWLTyh}KBuoQMSQOZ;YBAO;jF zQ>Z8_;K43h<)zO&55846K_XpwRcD|e@?rV>#KT5L+5(6@->S@IvlHMQ9KE2B^T*^2 z`fa?by{|02A%WCetXKyUL6K@Z=io*>W=FY11$+bk-duR4^fXj_4yYzapxz$k74y)Q zcR=C(063?408kwVc+W-yW+I4K{34+g914qP!?y@6eu0-P?>R$f@@G+z0!<>BYT!!nc0Y9J<{V?Pgr~T}-y>kR+2vqd}3T_o@ zUIGdu!g*Gp{Z&9$t}j`_i0FASr;>8p)AGL!wJQZ>qSwXUWR3V6M(;1jIfLL`2d6u4Z3bi0L>8ptUh* z7NT_V{Y&ZZxreg!XDgj=!=&yeLV~Q)B9c7dy!C(87?F#@VqKbaBe;Mz`j-8WOMV%>XP-D;jBx0!gf!5-1bc#&`l)3}A z;TJdW9Th7G&S-k@St9KNY$rfd1aRPA`Z%|{5jHn85HizX+YU4izyOUn56Re4bEg<8 z5P6}NSc}ImG3xER0a3P&P#s$9JP6Memm`_tUP*kS#fRUu`D9VlGf1QIkNHNcz4N-t zq>E$+7#ZBij4Y9e2Q`vRN~B;p4gKXq&n48)QUBQnZ2(|Ritb_hTD9r+p5u}%egRai z0GnW+a=S*kwaiEp0F^BXnH%7mgsKYn-Qf${{xOQ`D(wVv4zkpGEM^>Ym6PC&uzs$h z-PxaJzh2AjJxIVFE5L)tcI!<)5`A^mnS+VSsHWMc2(eUp1(tXY`QGb@%Rff-?L;+8 zKqP{q?Lb@?uA*igXtPIszz$UXRG-HA{V>#3qryS?!yu3>vkIMvsGBm$PF?e4=TQ$6 zi4MdA&IOPHTalc5*v^BO734z;r$A&xYeS8@%{lQs&^2s0i>UY@SACOTk+`NQGdi0K zYFb%Ix90mK-wd*tl;tn$fO}KE1W44k%+O{L5`lRH;Kn-$;7x?2tPj#q)}XY$jv9M_ zf{dsZqnxkauh=>qDf5fl`)jcKRQeG?EDKjKj=A+`+kH}=H4uQR%E~c(cr-Q zQawf1Up=j8*}DhQsK%Nb=@KFE+y6(|zpgo!>o|ZYNay=M@T5h|4*-;$wfmfJs=IbY zIF{EhmOCo*<0lbS0Jn^Nkfj@?h=eAx0dG2lDwAIvoR^R)U#aXa|{V6`R4dV`Vs_eR^=iMQRu_&=%#xPqQc)?G|m|2P3Wazs3V z`gztU99K-FTh9#pVN*ThIA+X#_s-AoS zAGqbHALDT! z)qX(%$Wq_es2`^Qd9b62r{m0k1Tjz~ApgV~9H4)V-r?_sjpVpn@6DqPyFd>5^P~#) zNHy3}V(9*=vfROY2j%T8(ypKXrq-W?mAKl0Om&l3JAtlCsI~hS!RLpU;ZLCc>H71W z=AB$W_WK_DW()b=y`e2|zpVSMaNPI0NF+n@I-cj+ufhN4cKAO&dwzKl0b;=Gc>l-u z|1aamYy8;n=Lt9R02rrkuc54=u9Lt|iU3^l@Xh(-5MBtL^PzYnv0gIQp-{Qc8vdTo zQ;{Ji30R4Tpo_v?%;WnF^B+GS1VVpsot`%8Oxh4~?=O|#@1h@y1SD<)knf)RI<3|D zbH~9$Go=(@$EM z6Zbg!%RjI=$CRS`5^d0Y&`|S6Vf=?RH^k1p1YZBM6!&=x1dwI8x}_%t*n7%xMmn1Y z;5UkUfLFF#wW-ES_z^nxU2C5^9@|2kkqE}?uwG%$U))^&w+X04KDUuxW#vW@hQeL_u}teo2?(C{^1O; znZj>sald+>3IBY~e~$V0pX*Pb^Kd_VPx0Qzd#(NP2B!OYB9@~?{<6jck+PA)J!fh1!jTvFHRiDHdyeOxbxb{mF7ybd9zW%*C{bZ|emTLb*N`Uxr3S+a# znEY&DI-1Gu56D7(vYYN2x{sr6wcr^R!6RS2W z>P18Ft;M9{e2~;+_a%{M{ol6YH-%q_zPBUmE_cZ@}7E7$(^KPX6|7!UE ze*VufvG*Ti{(%U;#{YBgkA-s&u50}3RQy-ILZIs*UPtj;8ob2c8eJCf5a6yCn6j#l zHp(LJ&NVXN!13DaZ}eJ&j8mzbBKZ1uT|rj(+3;~4PV@#FDRa$r82HKila}P?Ae5Xl zh8NKfe6B8)@0J6v%h1bV81D0y{r=9a@4v0T!up5Xpg$|d_?derC5z(Ex_`i;pII|p zWH5t2JEZOh8p6Y6n#2VgX=iQ;KRHrwoUHn(o)rMhtW&u?LF(xVVkffazdS@H-d#-) zYqKHpK?IK2zgNFVPUL+%*I0e{vjXoPF9UF`_vXw$tG=Z4JiZO-&-y$@30)$w0yo zUoT=S4>sPK>xl0&daXYn;*Y%b|G7%PIJYnKJ{ImX&lOH#{H)+SIX?lM*;=R^pKhc?poN(>BwsxS;4c!8P1M^=4{V(c>E5v{Qc`R-nVjR^!|J~#N zkJGHd-&5Yp|9i=|Ip;rKX^k@w`Z>Wmf}cmblStP48qlwr&oDigl~K#{@;?S7ha_E? zpfzP2#8_7TIs}gHWek0<#1@D{xg5i7ypf8;eildaIxxUH`eo=-zgrCzhL>=)+5&OpEBDrv)aLy5(&}Q+3voXxI^Hhb8pEFuGzaZ`QEF3 z-Wx-1!rt?&g*?w;1G)y5rzEy!YSPL*h}#Fd&K*>x6`tNh`u0> zs45P%ep1OrPACL7`&Gc% zIwqwARCk?BJAKK~Un3_&Trmy%7S!u`q5UbK29=&(eklSf2MYPN%^Cfk3IwhJMbl4G z=;8^LG&^yzC$D3O0YBv4&+)OejMZaZJ8rLm{CL0o&#ME-;MfV|n?An|O1S^$#6Rb} z&Sg*WSZSOfUWbVHbU%py_dM&5_wIaI?$3O$u||Cz!Wuf09zL(a>LA6K0zogmeVR|q zJ?-5tEBW`v3Z`fMG4pinUAC?bAo+f6rd$yP=5t5b~New&IDr2wo3CJnhY&kove3 zFd%y5n_Y?Woul*Q2QUBw6k3V{h{pT<{^jdq(E%Le1h4z{(`)uIDVB<{( z^kAAsYc$*l#Z(0zLyRN9jZ`5E_4m|~o)vB|f`}mo>IU}n-uoIV@J~bxk1%jcGe!9F zu|Wy1@dF#B^Onx@Y$0EcP3A;#>T^V*t9N~o#BJxT_aejB^4+cT`+I+9UT37q$fvCr zF3X>B;sm{1;~&mXDV25v_7dPwLZIyRcd@T$RuzWYqTz83w*OkYTkIJOp{fBvs<~j$ z#GQe0BB-&W5od7gd0Xpmq@{SG`JisQa6S-ue=JJm;$5_nL5~B~N56T$C(EAUA7g&4_a95nXYY3`9(sfKJ<YpsOaw zHP~BHoF9P`5vJb*B8T9GM~?osGc5+v-vQ!}1a_Vb)&Oe&2gBZnv9Xe+<$^@WX4?!9I{kK)|=$*5)UV07yW$zpu!27Cv7R z4!DfECh)_!XR;Qt4K-L2?`TMP2*)0^`;y^e;e3o1s3qS}fG#iAX=2zBkd z!AHh74%h}LfkY?(U+XIOK;Zwe6n8~FhWlUeXBA&0!g8)tK}R5BqZbL~o!gn@XYA*C zg7N#TcYstRW?0ZVn3H>*f}3mVXTrI~kv9!IORm^{nx}6^#c@?AnOO(zbKmzrU{q z`Ys=Ta;^FI(723!20kaQ4Cwpo4du^OA1C)XhD^}MsQ>*rXUR1TI%D{>x2d23MImmM zv<71%d29DSBwHr>Q-4>UoXGo}%g$p3I3VU|E;{e0(u1q$8#{w-_UUWPV=Pw-py$5C z*^RvKeWbjIeO8asTP2wrR8$?XqbUWus0r1yBdk8X!Kd!&OY-Ec{m^I~&nj-VbbtbIgin;jv3R-^<3NzKciu z;H&Yek9u+^;;b;J_?*{2)Dz90iTXy~2XiPK({?&*27~Vb0g^xySH1 z&lUMZ2ja3~<_oZSXU=!wDmS{*@c0^)Yw^VGV8a)#^Y+Jcg=q7B-1+zQNMx()XalMW z-vP$=YXgcXrU2*Op3H}X)fphh9@J2`{I@otAaQIH2gK(|0lIwOJD0qGXXX-m_*M5w z1*qXYaa!5|hw0nBj@9AKj4R5z!TH>oSCe?a!`&;i{=NtPR zRXeP=WL`QGM`**>1%vCfhvyph0>UE6(K`s$|!&X$aW7d6DlG@qI`cEKc_ zd4?c@*vu(V>xO7NemP2Lk_b)cLQhYUO99|FfdI}~d z#S($Y`OV>JSgy;WGBGu{NU!=trGXk-tWYJ!mg7oSR2gtKC;!?pz3RNL9gfO*nyaso z{YwJwl3l-G+98~M&Y%G~7d^qAgF&pTeTmcE>2}$ElZd7CMyuby?t`vUA8-_q>lAqg zOFG7xC7se#nw=60|1*w6I1n-o&sGUY+a)n60y7REKR-&q7>)=vMPwmP72^b}E2!NW zSeyWoGHAh2pndVO=p84KFz85*U`rd14M$ppdM2>EayufvQ=O;{j42}QVfi%hWCzfW z%DEMssib`MIQpqYMMXK~IZh9O%MR zY8WyqLH0-6jtr&*{Vzm+aQ9lDek^&^eEzBe9}8P7W23)L$llY<3D>dYx`7%Ox|@5G z5_-c=l(}+?_#qsgpZB(Yji2lM>i2IlWV{EiBY97?Wj{yS8@a-!$6;^{ec`<$gN!9( zi=AW}WqBk2ow$MCA+)3x5--2w~z`!N17>Zfrhh#&&) za{@|zK2>=heXf%gYHI4Q* z;KQx3iLhfPHGyicg&uT;@{fTN)vZzexP&*5q}chtMq~8%F&XJiKe%HfJ^ z?K;HQ3E3stao_z2fAjRcK*b4O)DRwGjrf}H{<0DNCe-T-uAh3Kc0InJobA*-_YMC_ zR(b!lEPVclelH98_iyTlSN+t6GvVS;a!-36SUopeHv1`&`FZRZ$RdusKTI(_wfWil zJpul}@5i_m(dQrb|2m$x9oM}VxFMCG@PL&%|Du)R_87d4LjW9MM6m{4!nV z&Vw1*qwZBBJ%i!k&kzyF(DPQ>OqPw3n3lYkE+t;&pJrjLv8=JnYF$E9iYez3YpqsK zz;ct(AyLPg0OxFVv^Z!6qToKM4m>2A!*-Nj$>n23MfsZQgm+xt!YRO(2nV}su}+^# zK;7G4`y2eAoTL#<7Q;mo;tFK?+5hf2k(m0esyuFlffhTg*Qg^;wJ}}|qlW)yBTe1Q z`jY?Di*qZ9zBp+HAM4K+sisFC1!3m2-K{=J_6z;SWzhfLVUEJb)kGyp-;av(NheYA=gb@$tRP6ZMmO^FC#Y2fM!9GisUfC+a&YGlsIP zbYZIilk~wHmbaqfjM~p2D+OnxpN&+;1Md^4vfxAV`K8+MNZ<2qkA301?nC(M*a-zS z(P_LY6Xvm!`ngZlnUN4K!=I)ees3wurCZN&-v1queJGdy#>bY@+T}>#G$jTd2TAn| zEG~(MgKO-~n?f81FnYj)BJdbLuVxtJ@!Zf!{>1C|0H=nZ{(zjC^y-81bT(OYfd1ns ztQp&oYVR`Ti^26YU8M~|{aOX1-0ZFPo+Z6X|}$l9TVXhI8dQF*Yf7ej9a^q+ z%1nO@-@JmJT~6#S?In>JZSZ((_z6t5BfE<-{4VGFYUJ_t{ojTkgP@M@iG3x~OA&W$ z>yfj|nrFvQ@5IO?&pDY2VO+TX0!GTd-9Y~E1e~$yB?Sm%sxq{Y_Zu$&^!AL3!reeQ z_w_Q)Zf=Bi7YHQ@XE1-XA7D8*?25+URRIAXv^ERCII0ssjhst;ytu;m*(~9v4?q^` z6U&B|9wi4aPxuI2D#%@Ld^Zou%#_F4(&1yIt9vNHGTIq>!#W?U7koRXzynn`>MyCd zxBNI4+yPwQkzy0~Ug!yIRK-n0c+Ziu(&Onu|9viAnQ9FDGX47C5@?Rhk2A<5CTYf> zyO75fCY8p>h3~7ar+2R%i+&K+-=(|+DBKbnq4Jr@zKxht_bJl9LYjM>i=k39F?XsV@*G?W3+1Wfco`9qLHB6Teq$gF$O0v9&uohMqv87+>8 zb4diT5t3z%xKA^e-QOUKxO<&P76!@q zTq}=JGv5orxPLovc~DDpw>*jP6B#cc{(7Gq5GpHeVMov3x$`9oz)XbI=^y9mGpxVD z;dKi9s)<2;6WNRPJ&K~^KtUUGp8s9N7sKClZZZeBTpj5iS(EZ!9Ji#z4Q^>L&ZTM1 zz?}V@Gx(#OeA>`wEXDmG6^D;D3@v4Dy}D;*_EU||3hdSjs!)fdw+N^nif{iQ0vG6$3+C_)v4v{M2hc9#5e z9Em!UC3ocfH7xuY%0QnJpgx>d^1xDAuRFcOoWQjJm4K9dSV+B~Z4{&mbg0%8ubLx+ zEzI32?8w-wLJVeBwbX|VM(yTE9+X^hi0}kv?(>P3^*A7_l@z_pdd?VLDjjKJLM&gf z?~wW~?uC8ml@$e8Y;lv9gnQG3A%4c>QV19FUNnIlK_24=aQo+R)o=@jP9l^Y!c@-x zOI*FH>-apXHm+0|nJB8x%~~#C9fpF)@&k4bxz3H=DFr^L<6P3uOJ{YhI4v1$g*wWr zKORN<^H=YOf{;5+liuC!=Ip@otPgRTJpb?E52DG=zClaBx_+UhE10wMAJ0WnJVDf! zg>93PNz-Tr_In)^*m5*V8vxYcKx6$h z45@XjKdR7}x+_L`Umm<5pd6E`$g%d|kZ#>|m464^c3ydhD3=A}=qB&W+!r|-nnhsnza zJs+&TD`Fu?F*Hr|w1RM#1x7M(dS!sAJ`m3qVTO}A zk4WF)I2(<>=Ev?Xe9dDmZjICkfN(TR!N`gSO{CiQo|?%|UeBB>7P6;LPI$#cnGp3F ztv+>xnBA==o+!~BiRzCb2GRIW>9(6Gqxn&<;HGxc(UVfS@~j3wLzCCxwq7dh>FwgU z%(vneML}j|pLqe1dIXNA_^?(Cbij(S5#bt+?$PtqDFQi3OXfFXQ*(ro8OQO`ThFAD zF@pS>V|M1fHfG_DDiDh5Cidzs+zI<15zkgs_8w$M-;tbmu-pn;a%lp<;qYTJ{X1hm zZt!tciZFu@ruMgE&DU>?OoH zS4cS>BcYBx#sExz&>41r-LEH%Mx#UD{=&xJ|z__+5$pUX$;)(Ht z7$8Rx;x0$(esgS$-?73uMy@Zm*r+E_D3j37y#%4ptH~QUT8xlNJb*5c#Vo7~-49hh z(swUe_dSS#l3)$;^FrvvL#?7?Re;Q~KuAc%Wk^-Ije2UU5=`0ec~6zTql{zsdKB3B zQC@f?>pw>+XZr9b7gy_UIhwz-Wl4#AsO`NHwd3$Y+b|t;^EpHE$(q;t4gE(%p0xo)V{k+QGP4!7Sp95u-(VyX>Ow=SU796W-;cpj zE2?UFfC_V4ALp*;96JtqEo})l?2^_`u^^iSXr@j@l%qHtU(+hudXb+w0tsD6M& z)ZE?yuyI{oZ^dTU0Asv?YDyF?G?419%x*r=J?TrLbk$ZKfbyb9hu1PO#Ap$AjyUDx zbtSxH-g}08lKJ&k4RYHt!Zm6;*hKM6>^KGR=H4-;1dU?{5_muBFlJfk7~JqIV}CFU zslSHm1FeVywj)k~76EaIL~bjtf?I$Y*N|7-lY8$h{%Jo~P+IMa;==rcT`Zyud+Rpj zDN@yi;|Xy99DqkKs=ge{d$FRNONiF<&arVUYD&mw;7p8Hze`u(Iqx^As2?n+gAaua z*06UTc@}V!ej9!UCxq`H>jX+lKut!@sZW2l?DOGbWek*q54b&oi^Wcjqz6?Ww+##T zH^nJ*fpVw?G&pnYIJA1IyD=P=5`We$Mq(FYPT2_*+7O0C)R=^ex)=vN?fhN7ObtW| zkpyq_4s4C_cu|o?WU~;D;8K4u0E=CFkVRb^pjA~9!&_36CtG=JCBKB6L{fXB)x;?| zIIQp4;Lh!H^B@7wk{B{w=O5g>UCViWu?geuzxx?q_1(=w9SIraFf!~w{7im0N1vbq zU4Pf_uW<*)YraGb6Jz354@zS(dLLi=@4XIhln>d)kU|efq77s9u8&LHN>obF2>Z@4 zUT(5#KC+HuEjjo?^Vw!pGp=SJO+6lmi+E!x5))^VK`z1S`n|?xBSBP(cI+}{A%eL_ zu(m=FZ%#XxGm4Nx7u@{cdI1)RKW7G?ZWRz}Ty8H6)%5674Z`GJQ5UafJ4!Z7g6;Z( z{ejT!0H02O5PK?kB~gn=a)9cgHxMHwMtYin5El7k_(+sdDD*9wRz<8)Yyw}P zKIMvo%RtqrqX{We5J--lWz5q8?vutYv0o9k0he z&~D7(FqHva`<&$ZW|t@@0goe70@471O9E2R9z%9bu^ZX7*OEvAViYCi)$J1^CqA8? zKjS?;ddm}2jc3cMQmLgKF#+%>Wf-LstU`C!j2jIQ@-Gz3Z!^->Zq~?Ai|_-qJDSx8 ztZ$({g`fo$0cx+Yc3$H6pAW7q7z@D=M%~%YDh311@_lATF@L=!zo3ekZ5+yV{9GM@ zSNHI_14ovLm)Ud1_~U3{Bf3p54F8@8m1xhz4rrnVAuKuQ&mU->e7(XC3?X(OlJV+0 z-i9jc(>&gR$WyOM&QU+`x7fcEGC>cjV(itQ*kEDz-^_ zMfDszas;^~mtGa;W-otx1Zw-0KgtmrRm1A4Or-430>&Ar5@v@5lsb*F(w4iN7Mlx@ zSZFx`as@iYGpIiu+-dkOGErsyQBT4FBr}vMn?p4%?HG2K_!Ifa46Fu06+$LXr1=<;)~bi&Dd*YLy1j3?m~0ueuJ7Yy2+b`5CjA z=_?1fO2v7|JLwtlW3ZcYTFVaF-XM}i$&Nfd4o zTbLMNi332ig~|(57*q!f50<>SVE<@yf~rCpt>}hT4bUn>8FRH4{4{XJl4M{d#DUi3 zt;p1r#9~$}05q*O=~j~H$&^)s7afF`-Y+UUppP%!Y#`*{Gg0nEE`qp*je8wF4&pWb zn8f~Ej)F=}GU4N|R<3J`ZS#CjI=9N=*1FAg7>D3&5c1-lTG_9{h~W~4FH#TyC1XJ9 zkq52uq=~lc{MtqemuH~eegIdK#Puxkm)GhPpJsq|cPaFC{gHa_h@`Z$`qDe{5?}P82=7>4>j3SzI3}S=*Rg@Bn}aEV!oR0I?+Fq`88@g zjd6L6ce2r*Xbfl-ef7s7c0-RifY({XfJ5f#@8!hdGTZ^0Sz=51$aNySrFFgOMq%b) z)qE@-j+PaB!fy*PFa%)4Ln*(A6M-mEQaQJ5qQSAsmUE7iiTI)Y-7AXf&GMJin+pw%5 z&_EO<$eSX{%Z!GNDweo?)rY&aDX3ye7=F)|ws&m7`Z*dvRdlFS1;C61LLFER(=nGJGS zSBY0^ldK~pWB;W}Q&!V^WCl$THYfAxAoeD?H)Ni(T7~f@28@Ww#I}YQAPvgL6i7%NPGq=)>GLx24IWm`;a&y zcqUmIMG|n zH|;c9FeH1qrMqF_;~*vZJ?e}{kl+L@2Tc%LGV}vFj=1Ug?C01L4#w*R;@)o5Cx=!G zf*kxw{yYchuKEl)&2kqYgxG5>D6CW3I@}BT0`KWnIE7LLK+>Mp@_!yiEm+l6tx48h z1R7^fbzcQmw{E+&qe#`6?dM-*!4GSji)sN!8MhR4lT9tnC~K8OywteAutfn?EY?Q_ zOOIrBOQv@$kY})|(D`FRagBd;lo}(2N?o^{4+mV>p;wY;M}`X(;YnfkIh3fh2B0nRQp@-qAz^@9eu3c+padAIXQoGJD7vk&|ZIo=VH1kOp- zK&57-6%p~=NS*Q=p8qYUJYL>&xf<9b#n=H?04e~x+;mfnATQ~6tisHWJ*h^do!63) zelMkGQvU7h=N;SJoCF6Yd4!(vit_@r?RoO~k?he!t2uSfT%)(Sdqy}_l{d|2sY@o$ zPbz_*&x9g)LI7&QcrAcd`l$)XK}qyO&MEfCx|snflN?N`v70*oKx}=ypq%qca2?ND zFS2^LX2n0)VxD?tNp(0x{V8h>DhmnS?ORa3iz;QHym&~|NVh~JpFm|>dQs^Bpr#^I zt@AdB`L2Yyijf>3<$*wpLV3rwcUm6HwptxKF;6_#(e!9T<^YV+xs^&; z^v*TRioNVGJ)Fw;6|}%;pS6sXk|paUNwHF}I$DS=>5c=5qx95xr=GMV9~UJPiyAx5+53X;(YZUqjG@qi+mCLOU+ z70dL)q*%;(jZNpzgkNGzBNXy1&V+P-_+0nGVl&$ptx%{S2w}AXf&P1~$5G%B2(4rA zGFVJF zNqC{2r^zhOMBXr?CBNb#;v~vme=rttWGz6IC4;(;Qvl~sP`^ojtE1eTh8yw_WYvf( zQ0YQ;+70jgAz+3>%%@zJCs-dXCvFg}kWjJof|Y^*zd(Nk^Si_3FR2UH0g7F8X+vZ{ zHA?6|qiBJVhM=h&6kqWPs$~eJoM5<89)?XldTrZv_3-opzxFp(Z6n=f9&5P9dCG0Q zC`5KRpq9yQ#P@6v5?rH>g8;%0j6-mYOy9fZKGKFTaH^2}-l!s(NG1y&Lw7)kB9Gx7 zobM8K#X*P`VF7Ts(`^nC15Lpsq3;II%!EO+neD#qdy$^;Vr`~=I{P@nzg>f}XQ2BA zlAt^J7QZ~+M*zF%h|tQsG#jD4{KV-yRi9#w#V>*N2?~ZvzNbWN2FZhVT61o109&(Y zwBWKB^*DsA;cI*==iMFv9z-7(p^q9NBx+D6Ub1i~2M|rZ>r;{_0<6IY1p1@=Z$`WL zoMtz`Rvi8Sb*j}zT?`_MX7a58C@&t8+J77hJ1cWh1iRgNzqF-M0X2@yA=6N$(jB+? z$f`i($O^=iw;Q#!5!JX+%?C=*f4Br?ou1I>;!&{cHa@O+r6VOHHI;j2uoz))FJ~r2ej3R zQ(2~~ReDA_sAc-4XHc{kNqG%9ItI=Xjz&!8_YLZY(#uF7X(zf!4Eu~B2V?wz+PN8) z%Z`gS$ehR{7g!H*FM8DLP@?X$gYbk2(xlS?!IVYs767diAn|9;IHaOJpAS8)6@tp8 za;o(g3F~rg`)vJRREoHQ9tgtn8kv)(|;d-J!(f5*TCCi8q(_>qKQ6#UfbVY%c09N zmtnrG71}RA1;As_P;I@ z8qqK<$-j0sho~o9FV}*PsKnQJE7Z6_@*-)NUt&}WoH+w*29bRtBm;L1x@PB ztq?gic;C!0U3=lBJXk{y8gTS*R-_biMv2T=1?U&Cs=@pH8}*fE3Jv@j5Y4%2 z)Dm8z+CBT#Wk*dAKLpQIbqhD25)`jQRuX0F_;gj)J@==SueMM`bxRr5pDJ1q1VZJy z4yjnd5A99rRfkFk$WsLf zc@Sq+ljVGKLty3-Vg4$x)@*tul$E1YN;DeW2SN%=7@EJ@^#2N*-TLOH} zDOgXh8QI%J7UKlbrdj1UhmJaM*6GCT^;uvtq0}i6SVt50Gt<_9bd} zw)BlmWWA*2Y_9+HyT6-%xLk+UuDC`1wWA|cp3HLqs3|+6I9f)UHfeLVJj|PphG=Ag zJSWM@;v9SAbs<3o7h(kq2_Ziz{r7{cCdbR%3Jj?PS3Xk@Qqv*B5s$z zI=KU2G7@=R%)OO_tx!aR?H`|cxB>IeZks@x?D)*S@FDUJ8)cG z!Uc87{SDMSW;Z(fJzr#RfO^YyQR!s{7~wq#>B4oLe%A2)%DIqVgFrHexD??5o|{TOs5HM{6fhy~z@QRerzjM`cT&qqs3*&;C_mZ) zBqO>!xZA-;1S5gR1=^xmw3cJH6sLUYaMb3TRi{MUB~L$&-`b30f=KB0+DU0{)6?1> z39=kJc=t3=%3%ktS1`GMJ2K(V>xivA`6o5-J8&D^_zPlPNy5Mm*M;mkSY#g;$z_Qz zctu4Tm_++o7*R)g{!;?rrEcRT~M`dI~El8Jpp(@Fmbu z7uE9TAcaa7UH9ixL0c1X>|(Y=c^!iVGKRRz_J2!&eH>u~GDDUbA0}vM>I!-mwiFul zP$*`>4wJ7S<=u5)I9czE%yV5UOM++Uc4LtIwn`gqR5}Rh6`k58!<>S5;YGFw$5i2K zMtY_=m^ymQRSv2)p^6bQ?emV}6>YRtE}N1{#ikfaNA{#F;RrlCMYp5#<3}h|V1ni5 z{YxfVNm39TQGgw!!;c<-G!m%w6wB@d7;m^nZl0eqLeKywL?Q7vB4K233g<~>=I-nJq?)7OJ}m> zRKbk5aYKg(s5k*mLQDs$awEFu53o^&o|wxqtFBFxhslwJ5{&e&FOgA;mzgnoAnUs; z2npi5IlfUs1!d?&g_Rt8Dm%-7E9FR+V>9b;v1%O=a~`7|FFQ#Wp8UNA;B6QxlzVJR z16-&-d_+fDqM`b|bPHc^se8zn6>{+;YcV@IX){U+7Y*W)rU14)p5QH(!!H!6%-zTy zBM-~(v*>7Uan#<)_wEp{5b>NS=pYb+5&EPydyulpcbKd|VLr!Q-TB*{m9Ru13Gy)bmtG;CA#>?H#CUrRiv5zA7tv-=@|i zVu=$VI8GT3s;b%#=~jiPYKsNS3w#Q#f;|D>>p+O5^6sio2iej18m||;hnFt9%P?N% z@vhNEjbgcg*A*lhZZW}*WbM((|Cmb-;@MabC4!;)Qt`MwG93?$_1Zxw-7x>pj-3r-fALW!Ge1s!MOi@=TTiJ#mFHuFf2*(=#5-Uwq^XOuk_QydW zIlL!}mggc^#3*vEQOB6$Tzn#x+dKk}K4e93W5-0g0$>uG7?Y=ejb3M22E+5RSjYCF zHTaEzTZNDEZ5ea%U{-c)y*|u`JxDJOu5Oj!&B=~u zgonD$iP~np1oTd{o`Sw`a8BL2XFkhq#+!4O+0qZ zVT~^z0CFUZ!|>0cy6Z^QXI}p_`cqnYp^l(vj>_O&r22bTmCAI}(cZJ_1CHT#0red> z`7UXnt5DS+Dnbv6RZ*q&r&06e;3LwJjrvp=vOxN(Dpfg#__*a4^LLYV8o?!}*I0!b z;%jZ8UJC%H>aykDdbg~yY<%N24`v2$puie`e)-a?5fKG|D2&+&D!#|}SkX+?>P?IT zZ$u|0i5yHsG+54FaMcZDUW2~T*8dmFUe4>y|E-QScSM)b05mU~4sRLm$GuHGif{9b zbME(@eZKwoNLSsz%Vj9mT9pVBiQh#DPFr#34owE8VTG1E?*YbUR=R$ug261LE?WtO66NcURbIuZg%t}DPe9MpU=j zO{;JTQ7WjFvk+js^iSI6)y~yt$+rG{V0Qp3{ryUn{$%%8vjoULA_=DEV3~5%;*FDs6E$*+2fV1JvByf-kRVpE!YwCc9&i?3i+r` z&!h5QSJn3PBh+s&%u*fn$O%-7djO;;6y>8A+YY6Q_9BL)^U?s$IVkTxSM62))v3!05j_iX$iiN+Ik8a@;T*aIBLcg%aw~1vnNYl0S}uPdebpi3n*L-^o&E zqkysm*9$KLZm*z6B%P$V&hL$akDbS`>I>#xwbe`zhaG>`9?Vz9e-gGUjJ<?)ltCJO~pxUbl*|SBA-nwLkj4A^n_vQ zff(*@YC!^L>i)jxq(mXa9yhrLeS>t<1)+(cfj{JNb6j0XyXJ^RZ)i184>)eAT!8un zRQCNMs*c-p6WvFxI!JU^U3H({y0oLkqOMWhb#Q=F+Xz%?|@FKEn^oEAu7?B{5 zKW;frB&+w{$nF!w>rJBk{Qk{3f1H6Kl@)?R{P|At)1&5$V14Q%bzW(uL{|}?smQ>( z-LRos1WJ#Z=~7z#IVCcW#Ei7^5$TDLkGo|S5viZfS}NF|B>`PK7#DW+jOHBS+(O+%o`1X!C>G_PQAyB@EVzAdurm<^cJ&C2F(WbM zPN7AN5;b_Z7AYj^>158hmz>M6-|`=_Ax9$aR_$wokhx@79YlkuXWR&H>0Y3`x9H-i z)QBL`fx176hrLJvRr9q3eZ2N7WwWF80WQ_quw9P(8h1^r+KjGk*X>08yl#>2R}`19->7-Jl^h0Vh2pO8i^KI?R9o}5fH=HpC-P?ZN$U}dz(-y zeV1!i|4}gjU6lZvlL3u7r|gvl`y$ZM@THz#sq28g2Sa;rUXP#1iTgjbgXlzorG}$1 z>^G0F_~K3uMCG@Yi=|a4&$P}W5h&`zUBu&DaID61G-a6nIGGT0B@md-JT&l!ckEt% zgPz+@ir~*{&Wt)i`GtSoKswJFX8r){fWi|*9Pty)`@f@f(_{j6 zRSVPP2-@AFJQ#6^UG4t-dLG4r>v2buV@H*gGwSLe6r`kObqj=307v+%6tWd3Osri4 zOWL_dMtECn`p1-db_AKfV?A!tj4>df-C+i2%%KEoUE`OZ@a~Bmr2x(X0K#hlMDD2< z9%z`(!Bkg~)%Y}6S_ABtGgV7Z99!Tq=X|#dCDlmDXpp%43VhCFzQ{hQ8*52 z3A~D965`1GcxvTB6vn^&!pm&kJ;!UQz8kF>WbD33x82rl7hbG!DNyNeCE5<5RNxz( zh*7zglGAty^=V>`SK4)UU1n9`_-Kq?Jj&q%v>5vj$yb_dR|Z)nT0PJjS11aV7z| z-#7d>diXPG$1AfCjJF-=iS&|edhCek4%N(^t}?| z5WbO(dKDzcwSQkv@VM$wGU|cQ)G_uyb~jsMb7mVo>*SeIX4MD&`a$#P;pMhOO21oL? z(R#?90HEQ|YDnn4l5v$0HpSnV6r|Dk%c9ZbugFM+l*pmz{ZF6nuZT) z`K?DyP1%h=^uoswM=LFg)M~)?GS-i1e&7Hf?fu32(-?1}@j+Ks?{R?se5e(D2L&x7 zwt7&k{F(%^y_T3RsYB6Xt4qBH)EB_E>k^;y`cwwbQDtA0t#%rs>$h5f;JpW8qG9Ej z0&eoY97MqKW1JLjw&w3u2b=R{N>o+x#ORG&o(b%a?CmRk-=Vs<$VwcGbqLJ%84nY` zL1dR9r!So(uiOZ8e=ajIkna7cKi-gDPzA5GMsmuVUJ)Nw(knORBQVX=TC-n3)whxcP#ZuR zmDzeXWpd~&8^2p!7=jGJ5s#t`!9xmpmiV(hcM} zb&N`)(sM4K30^_;`dR0D8;=g%+Qj|+zb0{);lGB+B^Kjs>^;AThxLZA{YiB}djV9I z*)|SHl$M=a;kBs29$NXZrUAxTkN{$NOKFOd%0LY&rc%6Ap|C5va{2K zbBBJ>f#OQ7y~+Lq6(~jQ&Yf|~uf-IV!(}S3xW~9|#-|G}_?zGB!e-}54XO>@DM5IG zplFs({g9{1QIFfH<}(8V)2U2KXFZ12M-Bz0t@HSW9% z;H!Lm%Q3BKsI9yP7^w@pfyjmWUaY`#zN%hi0L9tvJQ2W%jt@3ZJA|7q5UyVM z)|BIm#$;NIhmqfjI^glsvVkN0#C~`AAQO)VP7%R~i6T+B<+`*xufmz}S^&41*HrX$ zeAX%WMIE}$7u>{iqV2coWb!~2g5?xk%k(r?wKn@nMy?ts8K0QVp5M~$;71&_O42IJ zpAcB@u}tnCm+EWTd#5ejnCNZXN)Mn#ugCj^6-}{aKac0U1OBWzW|SHmsqGs=^7)vJ zf}7)u-7o)rCBShk+SG*H%}ul~06C|+`qG}=7#w~+ty`Z{_4zUPPA9W9Wff5nPAutsJSc2L(7U=l1 z?-#pGc*xtLk%ZwUYKtC%AXW$v8jj*i<6<)iCP5CeFwh z%2Umb3xpzkIUCZ?wE=Ha2^aEU#DOA>RBW_)>E;~_sPt}*?r%nWP|y3mahEXzX;mgd zIAj^v8~;PQ!61gZV@*;WfM7ctO?LHz)xNv-BfEL>>Q)!+wo*Dmr@RX5u!TC_`W zv03i>FE>jS0!-r7uDow!IF8}ev$~slhqHmHRXiP@D4%*i3LI@M!^A_~ z8frL1_YV5F229{K=%_6YW#iFsz1KK%yd6;R=T0q;{5a>-_X{khL8ujx8yLwF;~zMr zIj`pkdP0@<2i$8J^v2-@U5T1d;mBhbWRW!%Ap@N*fEavTlJ?k;SHLCBiZe|w;{n6d z_~Ek~yr;d6JQ?4GW_wT;*x5Z)rc6{pWe>lOgLktJTdC9X!Ol`g_Y`vWUO8R|5cL%E zxn|P%4dt^@!Wj6Yue_;)Z;blx9}V`w2L*NhA$wGJSDW>JwmlR~k^eGX+`I1{r0G za7cL~%YgV)){s=fFhIJ!5ABJ_)tEUtB~mBFC2G6aCV1CQx=ftmZu^c{pFN4rSx)PO zA>8&+rM)KR@7}i$a}^Z` zbdXssN-x&C&gVf&M2r>Mh;{}UlC5CnT>$BIz}O^!GJ2K67%1v&+EAqpS@86L!oL4` zd0C%(QbL3t7SJ*d8}<%Vsx_7W+ACBXxBqjFTHH;VOJa||iGhU>#_M}b9A_o~FKPh1 zpl6)3KefTK1{3eU>I3MEcbpWe3J|TdE}%68^(X7UH8w{NR?=DED{hQkz)`KeKgzvZ zJ7A&dyu1O<(In`PPyygpL?yve7pjM-_TFe?z5k(CXo(>=O~*gcZ5Ha|+3{(6yTea= zLr^&l_i1;C(jq!JCyu*v8UM+;@s$_y0B6!5>vk_t&uq$Q%jVrKUC^|vN`g)KB#(to zR(|NbU~n2bNYi|3|nF`8T7hY#ol&Tj-3hRv-oG7_`5H zCjTO_>PU21?IqhDx=Bi7ga=E@v(#GsBo^2xhN&_dnl?!8(aGZXt$^{sxu|G~54jQ-+QB`?(;r&_)f(Kj(W` zA8n;NtjKo3-P&vDGEAWxV4aysfFI!A-Y(joLq%At>)48 z-Ki8-dAXKj{MSE?KdV-IsVlRefsj9zp*fro`kw@n% zXMI$ZOby=Y1AueB24&;>%XBSEc7-|3!ztfa2RWo~U;agP&4fLuo$8v{n~HEMAy)Ip zsngP{G1JMGMvmQg+L{;84pHiDSnfLk!%*-gD&)f%K(E4rNXgbl)?Yb+^6+!5JH-H- z|MO3bTC--D0SLH@<4|qIckwFxoD^DhArE!a2H5HyZ%`_#u83iUKk`T(WscXxMUa%2 zz~a#eBs~C$KL zf!a<~x^K%3IpnS1lT5}7H@nz{;2#twxs7vXe`n#x8Oz9{S5 zcXeXB=3(r4;@T4lUSxn95pY|jT%_hlj3TR;FoL;?-er&DX(ljYVZJU&EUdTF_h$%N zm^c_*AraizM8A)(Lvp}B&Yi%Lbf-!Ye_B43Bb|FGkvB{!sVmUQgdoCd$hRc>fWb_WxVel_bXz!!SVc|391-kplqAxw^(vJuM0gNr@TR;ok0ZNKWPv zB=N=Es@v)TemsdNRHMU_{@JvGIb+-+;m(x2p)3(Q_yp?!VfDf%?SkNmmgaC?q4czoRyCh6i)i}3*rkA2KbJSz9lW#0wB+#M-s@9o?q32D26zS0#=BWpGF%W z4Aa}U0_lSY`}YaA8OY!>WqV1!MYtBrx}yo$!+R^X+@WW`$W5S`pUJ6&Vl}O6{#P;) zTzkiWs+qn02YX=p@Zw9-=E-+3IaNCVxt*D=#Q?!-v0HS+tyAqGJHU!6|53Q;B?XSD=G2 z%Hro6AB4~yWgw=H{|`mBYw6H+mDI+WUVtNLE>4Qb_5$SS^C$6XEFt7$!E$mj;ySmD zVbhC*&UAK|%Z&@SC1xJrtC=^LH0Vi!Y3Sqs3#vbsHIZVZhe@U4(aoxC#QeXC1->u2 zLed-at?BOiZv3i%wE-ajP|#n0z?)@b1iu_Ua+PC#x~2T&%Vr&@C-UU0Z12$Iv)Wx( zt!Z&yx^G}B6h2f6!Le2t zjM(#L)cb{&j&t=1P3^!9R!uUpnhvQNv!&T>0I#pF6axVEP=gmIcCSj%OO$gbLEE>s zjOu$}ycCdEnfKSfWg#4kgwR+uW0+L*H?;7Wyb{iSb! cWdUgL54fbh?Me?r8UO$Q07*qoM6N<$f>?Vov;Y7A literal 0 HcmV?d00001 diff --git a/crates/client-web/src/app/holdActions.ts b/crates/client-web/src/app/holdActions.ts new file mode 100644 index 00000000..21c703da --- /dev/null +++ b/crates/client-web/src/app/holdActions.ts @@ -0,0 +1,79 @@ +/** Delegates click, keyboard, and hold actions without retaining virtualized guide controls. */ +import { RESUME_HOLD_MS } from './resumeHold'; + +type Action=(button:HTMLButtonElement)=>void; +interface Binding {scope:AbortSignal;controller:AbortController;generation:number;activate:Action;options:Action} +const bindings=new Map(); + +/** Keep a pending hold through DOM patches, removing listeners when its route stops rebinding. */ +export function bindHoldActions(selector:string, activate:Action, options:Action, signal:AbortSignal):void { + if(signal.aborted)return; + let binding=bindings.get(selector); + if(!binding){ + binding={scope:signal,controller:new AbortController(),generation:0,activate,options};bindings.set(selector,binding); + const current=binding; + listenForHolds(selector,button=>{if(!current.scope.aborted)current.activate(button);},button=>{if(!current.scope.aborted)current.options(button);},current.controller.signal); + } + binding.scope=signal;binding.activate=activate;binding.options=options; + const current=binding,generation=++binding.generation; + signal.addEventListener('abort',()=>queueMicrotask(()=>{ + if(current.generation!==generation)return; + current.controller.abort();bindings.delete(selector); + }),{once:true}); +} + +/** Short presses activate; holds or context-menu gestures open options and consume release. */ +function listenForHolds(selector:string, activate:Action, options:Action, signal:AbortSignal):void { + type Press={button:HTMLButtonElement;source:'pointer'|'keyboard';opened:boolean;x:number;y:number}; + let press:Press|undefined,timer:ReturnType|undefined; + let suppressClick=false,releaseKey:string|undefined; + const target=(event:Event)=> (event.target as Element|null)?.closest?.(selector); + const cancel=()=>{ + if(timer!==undefined) clearTimeout(timer); + if(press?.source==='pointer') suppressClick=true; + press?.button.classList.remove('is-holding'); + press=undefined;timer=undefined; + }; + const begin=(button:HTMLButtonElement,source:Press['source'],x=0,y=0)=>{ + if(button.disabled||signal.aborted) return; + cancel();suppressClick=false;press={button,source,opened:false,x,y};button.classList.add('is-holding'); + timer=setTimeout(()=>{ + timer=undefined;if(!press||!button.isConnected) {cancel();return;} + press.opened=true;button.classList.remove('is-holding');suppressClick=source==='pointer';options(button); + },RESUME_HOLD_MS); + }; + document.addEventListener('click',event=>{ + if(suppressClick){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;return;} + const button=target(event);if(button&&!button.disabled&&!event.defaultPrevented) activate(button); + },{signal,capture:true}); + document.addEventListener('pointerdown',event=>{ + suppressClick=false;const button=target(event); + if(button&&event.button===0) {begin(button,'pointer',event.clientX,event.clientY);button.setPointerCapture?.(event.pointerId);} + },{signal,capture:true}); + document.addEventListener('pointermove',event=>{ + if(press?.source==='pointer'&&!press.opened&&Math.hypot(event.clientX-press.x,event.clientY-press.y)>12) cancel(); + },{signal,capture:true}); + document.addEventListener('pointerup',()=>{if(press?.source==='pointer') {if(timer!==undefined){clearTimeout(timer);}press.button.classList.remove('is-holding');press=undefined;timer=undefined;}},{signal,capture:true}); + document.addEventListener('pointercancel',cancel,{signal,capture:true}); + document.addEventListener('scroll',()=>{if(press?.source==='pointer'&&!press.opened) cancel();},{signal,capture:true,passive:true}); + document.addEventListener('focusout',event=>{if(press&&!press.opened&&event.target===press.button) cancel();},{signal,capture:true}); + document.addEventListener('contextmenu',event=>{ + const button=target(event);if(!button||button.disabled)return; + event.preventDefault();cancel();options(button); + },{signal}); + document.addEventListener('keydown',event=>{ + if(event.repeat&&event.key===releaseKey){event.preventDefault();event.stopImmediatePropagation();return;} + if(!event.repeat) suppressClick=false; + const button=target(event); + if(!button||button.disabled||event.altKey||event.ctrlKey||event.metaKey||!['Enter',' '].includes(event.key))return; + event.preventDefault();if(!event.repeat){releaseKey=event.key;begin(button,'keyboard');} + },{signal,capture:true}); + document.addEventListener('keyup',event=>{ + if(event.key!==releaseKey)return; + event.preventDefault();event.stopImmediatePropagation();releaseKey=undefined; + const held=press;cancel(); + if(held?.source==='keyboard'&&!held.opened&&held.button.isConnected)activate(held.button); + },{signal,capture:true}); + globalThis.addEventListener('blur',()=>{cancel();releaseKey=undefined;},{signal}); + signal.addEventListener('abort',()=>{cancel();releaseKey=undefined;},{once:true}); +} diff --git a/crates/client-web/src/app/liveTv.css b/crates/client-web/src/app/liveTv.css index fd62c9c3..680cdd44 100644 --- a/crates/client-web/src/app/liveTv.css +++ b/crates/client-web/src/app/liveTv.css @@ -10,8 +10,6 @@ :is(.live-tv-page, .live-tv-settings) label { display: flex; flex-direction: column; gap: .4rem; } :is(.live-tv-page, .live-tv-settings) label:has(input[type="checkbox"]) { flex-direction: row; align-items: center; } :is(.live-tv-page, .live-tv-settings) input, :is(.live-tv-page, .live-tv-settings) select, :is(.live-tv-page, .live-tv-settings) textarea { min-width: 0; padding: .6rem; color: inherit; background: var(--surface-base); border: 1px solid var(--surface-raised); border-radius: 6px; } -.live-tv-record-choices { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } -.live-tv-record-choices label:has(input[type="checkbox"]) { grid-column: 1 / -1; } .live-tv-error { color: #ffbcaa; padding: .75rem; border-left: 3px solid #ffbcaa; background: #742c211f; } .live-tv-recording { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; margin-bottom: .75rem; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } .live-tv-recording h3, .live-tv-recording p { margin: .35rem 0; } diff --git a/crates/client-web/src/app/liveTvActions.ts b/crates/client-web/src/app/liveTvActions.ts index 56acded5..4f4e84da 100644 --- a/crates/client-web/src/app/liveTvActions.ts +++ b/crates/client-web/src/app/liveTvActions.ts @@ -12,6 +12,8 @@ import { bindTvGuideMappings } from './liveTvMapping'; import type { TvGuideViewport } from './tvGuideViewport'; import { bindTvGuideViewport } from './liveTvGuideViewport'; import { hydrateTvConnections, bindTvSourceSettings } from './liveTvSourceActions'; +import { bindHoldActions } from './holdActions'; +import { openTvGuideOptions, openTvRecordingOptions, syncTvDialogs } from './liveTvDialogs'; /** Live TV data belongs to the account that fetched it; setup data is administrator-only. */ export interface LiveTvState { @@ -98,6 +100,7 @@ function padding(form: FormData): { padding_before: number; padding_after: numbe interface Context { navigateTo: (path: string) => void; render: () => void; renderTvSelection?: () => void } /** Stop TV streams when leaving Live TV or changing accounts, including minimized playback. */ export function syncLiveTvAccount(): void { + syncTvDialogs(); if (state.route.page !== 'live-tv' || guideClockOwner !== state.bootstrap?.current_user?.id) { nextGuideRefreshAt = 0; guideClockOwner = undefined; } const tv = state.activeTvPlayback; if (tv && (tv.owner !== state.bootstrap?.current_user?.id || state.route.page !== 'live-tv')) stopTvPlayback(); @@ -190,7 +193,7 @@ export function bindLiveTvActions(signal: AbortSignal, context: Context): void { const form = new FormData(event.currentTarget as HTMLFormElement); state.liveTv.query = value(form, 'query'); state.liveTv.group = value(form, 'group'); context.render(); }, { signal }); - bindPlayback(signal); + bindPlayback(signal,()=>{void reload();}); bindTvGuideActions(signal, context, run); bindTvGuideViewport(signal); bindRecordingForms(signal, run); @@ -230,24 +233,17 @@ function loadTunerRegions(signal: AbortSignal): void { }); } -function bindPlayback(signal: AbortSignal): void { +function bindPlayback(signal: AbortSignal, changed:()=>void): void { + const watch=(id:number)=>play('channels',id); + bindHoldActions('[data-tv-guide-activate]',button=>watch(Number(button.dataset.tvSelectChannel)),button=>openTvGuideOptions(Number(button.dataset.tvSelectChannel),Number(button.dataset.tvSelectProgram)||undefined,{play:watch,changed}),signal); document.addEventListener('click', event => { - const button = (event.target as Element | null)?.closest('[data-tv-play], [data-tv-recording-play]'); + const button = (event.target as Element | null)?.closest('[data-tv-play], [data-tv-recording-play], [data-tv-record-options]'); if (button?.dataset.tvPlay) play('channels',Number(button.dataset.tvPlay)); else if (button?.dataset.tvRecordingPlay) play('recordings',Number(button.dataset.tvRecordingPlay)); + else if(button?.dataset.tvRecordOptions)openTvRecordingOptions(Number(button.dataset.tvRecordOptions),Number(button.dataset.tvRecordProgram),changed); },{signal}); } function bindRecordingForms(signal: AbortSignal, run: Run): void { - document.addEventListener('submit', event => { - const form = (event.target as Element | null)?.closest('[data-tv-program-record]'); - if (!form) return; - event.preventDefault(); - const program = state.liveTv?.programs.find(p => p.id === Number(form.dataset.tvProgramRecord)); - if (!program) return; - const input = new FormData(form); - const button = (event as SubmitEvent).submitter as HTMLButtonElement | undefined; - void run(() => api.scheduleTvRecording({ channel_id: Number(form.dataset.tvChannel), title: program.title, starts_at: program.starts_at, ends_at: program.ends_at, series: button?.value === 'series', new_only: input.has('new-only'), private: input.has('private'), ...padding(input) }), button); - }, { signal }); document.querySelector('#tv-manual-record-form')?.addEventListener('submit', event => { event.preventDefault(); const input = new FormData(event.currentTarget as HTMLFormElement); void run(() => api.scheduleTvRecording({ channel_id: Number(value(input, 'channel')), title: value(input, 'title'), starts_at: Math.floor(new Date(value(input, 'start')).getTime() / 1000), ends_at: Math.floor(new Date(value(input, 'end')).getTime() / 1000), series: false, new_only: false, private: input.has('private'), ...padding(input) })); diff --git a/crates/client-web/src/app/liveTvDialogs.ts b/crates/client-web/src/app/liveTvDialogs.ts new file mode 100644 index 00000000..166cea37 --- /dev/null +++ b/crates/client-web/src/app/liveTvDialogs.ts @@ -0,0 +1,89 @@ +/** Accessible Live TV action and recording dialogs that preserve drafts across guide refreshes. */ +import { createIcons, icons } from 'lucide'; +import { scheduleTvRecording, setTvFavorite, type TvChannel, type TvProgram } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; +import { channelPrograms, tvTime } from './liveTvGuideModel'; +import { state } from './state'; +import { renderButtonContent, renderIcon } from './ui'; + +interface Actions {play:(id:number)=>void;changed:()=>void} +interface OpenDialog {dialog:HTMLDialogElement;owner:number;close:()=>void} +let active:OpenDialog|undefined; + +/** Close account-scoped options when navigation or sign-in changes, without losing refresh drafts. */ +export function syncTvDialogs():void { + if(active&&(state.route.page!=='live-tv'||state.bootstrap?.current_user?.id!==active.owner))active.close(); +} + +function selection(channelId:number,programId?:number):{channel:TvChannel;program?:TvProgram}|undefined { + const data=state.liveTv; + if(!data||data.owner!==state.bootstrap?.current_user?.id||state.route.page!=='live-tv')return; + const channel=data.channels.find(c=>c.id===channelId);if(!channel)return; + const now=Date.now()/1000,program=programId?data.programs.find(p=>p.id===programId&&p.source_id===channel.source_id):channelPrograms(channel,data.programs).find(p=>p.starts_at<=now&&p.ends_at>now); + return {channel,program}; +} + +function openDialog(title:string,channel:TvChannel,program:TvProgram|undefined,content:string):OpenDialog|undefined { + const owner=state.bootstrap?.current_user?.id;if(owner==null)return; + active?.close(); + const previous=document.activeElement as HTMLElement|null; + const dialog=document.createElement('dialog');dialog.className='tv-program-dialog';dialog.id='tv-program-dialog'; + dialog.setAttribute('aria-labelledby','tv-dialog-title'); + const details=program?`

${escape(program.title)}

${escape(tvTime(program.starts_at,true))} – ${escape(tvTime(program.ends_at))}

`:''; + dialog.innerHTML=`

${escape(channel.number)} · ${escape(channel.name)}

${escape(title)}

${details}
${content}`; + const events=new AbortController();let closed=false; + const close=()=>{ + if(closed){return;}closed=true;events.abort();dialog.close();dialog.remove(); + if(active?.dialog===dialog)active=undefined; + if(state.route.page!=='live-tv'||state.bootstrap?.current_user?.id!==owner)return; + const replacement=previous?.id?document.getElementById(previous.id):null; + const restore=previous?.isConnected?previous:replacement; + (restore??document.querySelector('#tv-guide-scroll'))?.focus({preventScroll:true}); + }; + dialog.addEventListener('cancel',event=>{event.preventDefault();close();},{signal:events.signal}); + dialog.addEventListener('close',close,{signal:events.signal}); + dialog.addEventListener('click',event=>{ + if((event.target as Element).closest('[data-tv-dialog-close]')){close();return;} + const box=dialog.getBoundingClientRect(); + if(event.target===dialog&&(event.clientXbox.right||event.clientYbox.bottom))close(); + },{signal:events.signal}); + globalThis.addEventListener('popstate',close,{signal:events.signal}); + active={dialog,owner,close};document.body.append(dialog);createIcons({icons,root:dialog});dialog.showModal(); + dialog.querySelector('[data-tv-dialog-close]')?.focus(); + return active; +} + +async function perform(open:OpenDialog,operation:()=>Promise,changed:()=>void):Promise { + if(open.dialog.dataset.busy||state.bootstrap?.current_user?.id!==open.owner)return; + open.dialog.dataset.busy='true'; + const buttons=open.dialog.querySelectorAll('button:not([data-tv-dialog-close])');buttons.forEach(b=>{b.disabled=true;}); + try{await operation();open.close();changed();} + catch(error){const notice=open.dialog.querySelector('[role="alert"]')!;notice.textContent=error instanceof Error?error.message:'The TV operation failed.';notice.hidden=false;} + finally{delete open.dialog.dataset.busy;buttons.forEach(b=>{b.disabled=false;});} +} + +/** Offer padding, series rules, and shared/private ownership on an opaque modal surface. */ +export function openTvRecordingOptions(channelId:number,programId:number,changed:()=>void):void { + const selected=selection(channelId,programId),program=selected?.program; + if(!selected||!program||program.ends_at<=Date.now()/1000||state.liveTv?.canRecord===false)return; + const open=openDialog('Record program or series',selected.channel,program,`

Recordings are shared with all accounts unless you choose Private.

`); + if(!open)return; + const form=open.dialog.querySelector('form')!; + form.addEventListener('submit',event=>{ + event.preventDefault();if(!form.reportValidity())return; + const input=new FormData(form),series=(event.submitter as HTMLButtonElement|null)?.value==='series'; + void perform(open,()=>scheduleTvRecording({channel_id:channelId,title:program.title,starts_at:program.starts_at,ends_at:program.ends_at,series,new_only:input.has('new-only'),private:input.has('private'),padding_before:Number(input.get('before'))*60,padding_after:Number(input.get('after'))*60}),changed); + }); +} + +/** Hold or right-click a guide cell to access recording and favorite actions without tuning. */ +export function openTvGuideOptions(channelId:number,programId:number|undefined,actions:Actions):void { + const selected=selection(channelId,programId);if(!selected)return; + const {channel,program}=selected,favorite=state.liveTv?.preferences?.favorites.includes(channelId)===true; + const record=program&&program.ends_at>Date.now()/1000&&state.liveTv?.canRecord!==false?``:''; + const open=openDialog('Channel options',channel,program,`
${record}
`); + if(!open)return; + open.dialog.querySelector('[data-tv-option-watch]')?.addEventListener('click',()=>{open.close();actions.play(channelId);}); + open.dialog.querySelector('[data-tv-option-record]')?.addEventListener('click',()=>{open.close();if(program)openTvRecordingOptions(channelId,program.id,actions.changed);}); + open.dialog.querySelector('[data-tv-option-favorite]')?.addEventListener('click',()=>{void perform(open,()=>setTvFavorite(channelId,!favorite),actions.changed);}); +} diff --git a/crates/client-web/src/app/liveTvGuide.css b/crates/client-web/src/app/liveTvGuide.css index cb1d984e..885ae609 100644 --- a/crates/client-web/src/app/liveTvGuide.css +++ b/crates/client-web/src/app/liveTvGuide.css @@ -1,8 +1,25 @@ /** Shared TV timeline, program hero, and the minimized shared video player. */ -.tv-record-options { position: relative; } -.tv-record-options > summary { padding: .7rem 1rem; cursor: pointer; border: 1px solid var(--surface-raised); border-radius: 8px; display: flex; align-items: center; gap: .5rem; } -.tv-record-options[open] { flex-basis: 100%; } -.tv-record-options form { padding: 1rem 0; max-width: 600px; } +.tv-program-dialog { width:min(38rem,calc(100vw - 2rem));max-height:calc(100dvh - 2rem);margin:auto;padding:1.5rem;overflow:auto;border:1px solid var(--surface-raised);border-radius:var(--radius-card);background:var(--surface-panel);color:inherit;font:inherit;box-shadow:0 1rem 4rem #0008; } +.tv-program-dialog::backdrop { background:#000b;backdrop-filter:blur(3px); } +.tv-program-dialog header { display:flex;align-items:start;justify-content:space-between;gap:1rem; } +.tv-program-dialog header h2 { margin:.25rem 0;font-size:1.35rem; } +.tv-program-dialog header .eyebrow { margin:0;color:var(--text-muted); } +.tv-program-dialog header button { flex:none;padding:.5rem; } +.tv-program-dialog button { min-height:44px; } +.tv-program-dialog button svg { width:1.2rem;height:1.2rem; } +.tv-dialog-program { margin:1rem 0; } +.tv-dialog-program h3 { margin:0 0 .4rem;font-size:1.1rem; } +.tv-dialog-program p { color:var(--text-muted);margin:.25rem 0; } +.tv-dialog-actions,.tv-record-form { display:grid;gap:1rem; } +.tv-dialog-actions button { display:flex;align-items:center;justify-content:center;gap:.5rem; } +.tv-record-padding { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem; } +.tv-record-form label { display:grid;gap:.5rem; } +.tv-record-form input[type="number"] { min-width:0;width:100%;padding:.75rem;border:1px solid var(--surface-raised);border-radius:.5rem;background:var(--surface-base);color:inherit;font:inherit; } +.tv-record-form .tv-dialog-checkbox { display:flex;align-items:center;gap:.6rem; } +.tv-dialog-checkbox input { flex:none;width:1.1rem;height:1.1rem;margin:0;accent-color:var(--accent); } +.tv-record-form p { margin:0;color:var(--text-muted);font-size:.9rem; } +.tv-program-dialog footer { display:flex;justify-content:flex-end;flex-wrap:wrap;gap:.75rem;margin-top:.5rem; } +.tv-program-dialog [hidden] { display:none; } .tv-guide-toolbar, #tv-filter-form, .tv-guide-filters, .tv-guide-views { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; } .tv-hero-channel { display: flex; align-items: center; gap: .75rem; } .tv-artwork-attribution { display: flex; align-items: center; gap: .5rem; font-size: .75rem; color: var(--text-muted); margin-top: .5rem; } @@ -16,12 +33,14 @@ .tv-guide-filters button, .tv-guide-views button, .tv-source-rail button { background: var(--surface-panel); color: var(--text-muted); border: 1px solid var(--surface-raised); } .tv-guide-filters button[aria-pressed="true"], .tv-guide-views button[aria-pressed="true"], .tv-source-rail button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } .tv-guide-layout { display: grid; min-width: 0; } -.tv-guide-layout.has-source-rail { grid-template-columns: 150px minmax(0, 1fr); gap: 1rem; } +.tv-guide-layout.has-source-rail { grid-template-columns: 10rem minmax(0, 1fr); gap: 1rem; } .tv-source-rail { display: flex; flex-direction: column; gap: .5rem; } -.tv-source-rail button { padding: 1rem; text-align: left; display: grid; gap: .5rem; overflow-wrap: anywhere; } +.tv-source-rail button { padding: .75rem; text-align: left; display: flex;align-items:center;gap:.75rem;justify-content:space-between;overflow-wrap:anywhere;flex-shrink:0; } +.tv-source-count { font-size:1.1rem;font-weight:700;font-variant-numeric:tabular-nums; } +.browse-workspace .tv-source-rail button:focus-visible,body[data-input-mode='gamepad'] .browse-workspace .tv-source-rail button:focus { outline-offset:-3px;box-shadow:inset 0 0 0 4px var(--surface-base); } .tv-source-rail small { font-weight: 400; } .tv-guide-board { min-width: 0; } -.tv-guide-scroll { --tv-channel-width: 180px; --tv-timeline-width: 1440px; max-height: min(580px, max(160px, calc(100dvh - var(--home-pinned-header-height, 0px) - 130px))); overflow: auto; border: 1px solid var(--surface-raised); border-radius: 10px; scrollbar-gutter: stable; } +.tv-guide-scroll { --tv-channel-width: 220px; --tv-timeline-width: 1440px; max-height: min(580px, max(160px, calc(100dvh - var(--home-pinned-header-height, 0px) - 130px))); overflow: auto; border: 1px solid var(--surface-raised); border-radius: 10px; scrollbar-gutter: stable; } .tv-guide-grid { position: relative; width: calc(var(--tv-channel-width) + var(--tv-timeline-width)); isolation: isolate; } .tv-guide-row, .tv-guide-time-row { display: grid; grid-template-columns: var(--tv-channel-width) var(--tv-timeline-width); } .tv-guide-row { height: 88px; } @@ -33,7 +52,8 @@ .tv-guide-tick:first-child { border-left: 0; } .tv-guide-channel { position: sticky; left: 0; z-index: 4; min-height: 88px; border-bottom: 1px solid var(--surface-raised); border-right: 1px solid var(--surface-raised); background: var(--surface-panel); padding: .5rem; display: flex; align-items: center; gap: .25rem; } .tv-channel-select { flex: 1; min-width: 0; display: flex; align-items: center; gap: .5rem; background: none; color: inherit; padding: .25rem; text-align: left; } -.tv-channel-select > .tv-channel-logo-slot { display: flex; align-items: center; justify-content: center; width: 40px; height: 44px; flex: 0 0 40px; } +.tv-guide-program.is-holding,.tv-channel-select.is-holding { box-shadow:inset 0 0 0 2px var(--accent-soft); } +.tv-channel-select > .tv-channel-logo-slot { display: flex; align-items: center; justify-content: center; width: 72px; height: 60px; flex: 0 0 72px; } .tv-channel-logo-slot img { width: 100%; max-height: 100%; object-fit: contain; } .tv-channel-logo-slot img[hidden], .player-channel-logo[hidden] { display: none; } .tv-channel-select > span { display: grid; gap: .25rem; min-width: 0; font-size: .8rem; } @@ -112,25 +132,29 @@ .tv-guide-layout.has-source-rail { grid-template-columns: minmax(0, 1fr); } .tv-source-rail { flex-direction: row; flex-wrap: wrap; } .tv-source-rail button { padding: .5rem .75rem; } - .tv-guide-scroll { --tv-channel-width: 140px; } - .tv-channel-select > .tv-channel-logo-slot { width: 26px; flex-basis: 26px; } + .tv-guide-scroll { --tv-channel-width: 180px; } + .tv-channel-select > .tv-channel-logo-slot { width: 52px; height:52px;flex-basis:52px; } .tv-guide-toolbar input { width: 100%; } .tv-guide-toolbar form { width: 100%; } } -.tv-provider-tabs { display:flex;flex-direction:column;gap:.4rem;padding:0 0 .5rem; } +.tv-provider-tabs { display:flex;flex-direction:column;gap:.4rem;margin-left:.6rem;padding:0 0 .5rem .6rem;border-left:2px solid var(--surface-raised); } +.tv-source-label,.tv-provider-label { color:var(--text-muted);font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;padding:.25rem; } +.tv-source-label { width:100%; } .tv-provider-tabs button { display:flex;gap:.65rem;align-items:center;flex-shrink:0;background:var(--surface-panel);color:var(--text-muted);border:1px solid var(--surface-raised); } .tv-provider-tabs button[aria-pressed="true"] { border-color:var(--accent);color:var(--accent); } .tv-provider-tabs button > span:last-child { display:grid;gap:.25rem;text-align:left; } .tv-provider-tabs small { color:var(--text-muted);font-weight:400; } .tv-provider-tabs .tv-source-icon { width:2.5rem;height:2.5rem;flex-basis:2.5rem; } .tv-source-rail { max-height:min(580px,max(160px,calc(100dvh - var(--home-pinned-header-height,0px) - 130px)));overflow-y:auto;align-self:start; } -.tv-source-rail .tv-provider-tabs button { display:flex;padding:.5rem;gap:.5rem; } +.tv-source-rail .tv-provider-tabs button { display:flex;padding:.5rem;gap:.5rem;background:var(--surface-base);border-radius:.6rem; } .tv-provider-tabs button > span:last-child { min-width:0;overflow-wrap:anywhere; } -.tv-source-rail .tv-provider-tabs .tv-source-icon { width:2rem;height:2rem;flex-basis:2rem; } -.tv-source-rail .tv-source-icon { margin-bottom:.25rem; } +.tv-source-rail .tv-provider-tabs .tv-source-icon { width:3rem;height:3rem;flex-basis:3rem; } +.tv-source-rail .tv-source-icon { width:4rem;height:4rem;flex-basis:4rem; } +.tv-source-rail .tv-type-symbol svg { width:2.5rem;height:2.5rem; } .tv-guide-lineup { display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem; } @media(max-width:700px) { .tv-source-rail { max-height:180px; } - .tv-provider-tabs { flex-direction:row;overflow-x:auto;width:100%; } + .tv-provider-tabs { flex-direction:row;flex-wrap:wrap;width:calc(100% - .6rem); } + .tv-provider-label { width:100%; } .tv-source-rail .tv-provider-tabs button { flex-shrink:0; } } diff --git a/crates/client-web/src/app/liveTvGuide.ts b/crates/client-web/src/app/liveTvGuide.ts index 0142d693..f7fc25a1 100644 --- a/crates/client-web/src/app/liveTvGuide.ts +++ b/crates/client-web/src/app/liveTvGuide.ts @@ -26,10 +26,7 @@ function channelLogo(channel: TvChannel, data: LiveTvState, hero = false): strin function recordingChoices(channel: TvChannel, program: TvProgram): string { if (state.liveTv?.canRecord === false) return '

Recording is disabled for your account.

'; - return `
${renderIcon('plus')} Record program or series
- - -
`; + return ``; } function heroPlaybackActions(preview: boolean): string { @@ -79,14 +76,14 @@ function programCell(channel: TvChannel, program: TvProgram, data: LiveTvState): const selected = data.selectedProgramId === program.id && data.selectedChannelId === channel.id; const dimmed = !matchesTvCategory(program, data.category); const subtitle = program.metadata.subtitle ? `${escape(program.metadata.subtitle)}` : ''; - return ``; } function channelRow(channel: TvChannel, data: LiveTvState, index: number): string { const favorite = data.preferences?.favorites.includes(channel.id) === true; const programs = channelPrograms(channel, data.programs).map(p => programCell(channel, p, data)).join(''); - return `
@@ -110,14 +107,16 @@ export function renderTvGuide(data: LiveTvState): string { const providers=guideProviders(data),provider=activeGuideProvider(data); const providerButtons=providers.map(p=>{ const key=p.source_id+':'+p.id,count=data.channels.filter(c=>c.source_id===p.source_id&&c.provider_id===p.id&&c.enabled&&c.available).length; - return ``; + const label=`${p.name}, ${count} ${count===1?'channel':'channels'}`; + return ``; }).join(''); - const providerTabs=providers.length?'':''; + const providerTabs=providers.length?'':''; const sourceTabs = sources.map(s => { const attribute = s.groupId ? 'data-tv-source-group="'+escape(s.groupId)+'"' : 'data-tv-source="'+s.sourceIds[0]+'"'; const connection=data.guideSources?.find(source=>source.id===s.sourceIds[0]); const children=s.key===source?.key?providerTabs:''; - return `${children}`; + const label=`${s.name}, ${s.count} ${s.count===1?'channel':'channels'}`; + return `${children}`; }).join(''); const hasRail=sources.length>1||providers.length>0; const connection=data.guideSources?.find(s=>s.id===source?.sourceIds[0]); @@ -134,9 +133,9 @@ export function renderTvGuide(data: LiveTvState): string {
- ${lineup}
${hasRail ? `` : ''} + ${lineup}
${hasRail ? `` : ''}
${channels.length ? `
Channels
${ticks}
${renderTvGuideRows(data,channels)}
${nowLine}
` : '

No channels match this view. Try All channels or All programs, or add a TV source in Settings → Live TV.

'} - ${data.category && data.category !== 'All programs' ? '

Channels with matching programs are shown. Other programs are dimmed to preserve the schedule.

' : ''}
`; +

Click or press Enter to watch. Hold or right-click for channel and recording options.

${data.category && data.category !== 'All programs' ? '

Channels with matching programs are shown. Other programs are dimmed to preserve the schedule.

' : ''}
`; } diff --git a/crates/client-web/src/app/liveTvGuideActions.ts b/crates/client-web/src/app/liveTvGuideActions.ts index 6fcabd4f..3d666afa 100644 --- a/crates/client-web/src/app/liveTvGuideActions.ts +++ b/crates/client-web/src/app/liveTvGuideActions.ts @@ -52,7 +52,6 @@ export function bindTvGuideActions(signal: AbortSignal, context: {render: () => document.querySelectorAll('.tv-guide-program').forEach(cell=>cell.setAttribute('aria-pressed',String(Number(cell.dataset.tvSelectChannel) === selection.channel?.id && Number(cell.dataset.tvSelectProgram) === selection.program?.id))); if (context.renderTvSelection) context.renderTvSelection(); else context.render(); }; - document.addEventListener('click',select,{signal}); document.addEventListener('focusin',select,{signal}); document.addEventListener('click', event => { const button = (event.target as Element | null)?.closest('[data-tv-favorite]'); diff --git a/crates/client-web/src/app/liveTvIcons.ts b/crates/client-web/src/app/liveTvIcons.ts index 0723e2a6..1a3c344d 100644 --- a/crates/client-web/src/app/liveTvIcons.ts +++ b/crates/client-web/src/app/liveTvIcons.ts @@ -5,7 +5,7 @@ import { escapeHtml as escape } from './format'; import { renderIcon } from './ui'; import type { AppIconName } from './types'; -const PROVIDERS:Record={adultswim:'png',amazon:'svg',bally:'svg',cbs:'png',espn:'png',foxone:'ico',freelivesports:'png',fubo:'png',hallmark:'png',hulu:'png',lg:'svg',localnow:'png',nfl:'png',plex:'svg',pluto:'png',reelz:'png',roku:'svg',samsung:'svg',sling:'svg',spectrum:'png',stirr:'png',tubi:'png',vizio:'png',whale:'svg',wsn:'png',xumo:'svg',youtube:'png'}; +const PROVIDERS:Record={adultswim:'png',amazon:'svg',bally:'svg',cbs:'png',espn:'png',foxone:'ico',freelivesports:'png',fubo:'png',hallmark:'png',hulu:'png',lg:'svg',localnow:'png',nfl:'png',plex:'svg',pluto:'png',reelz:'png',roku:'svg',samsung:'svg',sling:'svg',spectrum:'png',stirr:'png',tcl:'png',tubi:'png',vizio:'png',whale:'svg',wsn:'png',xumo:'svg',youtube:'png'}; const ALIASES:Record={adult_swim:'adultswim',amazon_prime_free:'amazon',ballysports:'bally',cbs_sports:'cbs',charter:'spectrum',fox_one:'foxone',fubotv:'fubo','lg-channels':'lg',pluto_tv:'pluto',prime:'amazon',reelz_tve:'reelz',roku_channel:'roku',samsungtvplus:'samsung',sling_freestream:'sling',slingtv:'sling',watchfree:'vizio',youtube_tv:'youtube',youtubetv:'youtube'}; const SOURCE_NAMES:Record={fastchannels:'FastChannels',hdhomerun:'HDHomeRun',nextpvr:'NextPVR',m3u:'IPTV',tvheadend:'Tvheadend',native:'Local tuner',dvb:'Local tuner'}; const SOURCE_LOGOS:Record={fastchannels:'fastchannels.svg',hdhomerun:'hdhomerun.ico',nextpvr:'nextpvr.ico',tvheadend:'tvheadend.png'}; diff --git a/crates/client-web/tests/e2e/iptv-providers.spec.mjs b/crates/client-web/tests/e2e/iptv-providers.spec.mjs index 13d71024..3e53a1d6 100644 --- a/crates/client-web/tests/e2e/iptv-providers.spec.mjs +++ b/crates/client-web/tests/e2e/iptv-providers.spec.mjs @@ -51,7 +51,7 @@ test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons await expect(presetIcon).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); await expect.poll(()=>presetIcon.evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); const fastFox=page.locator(`[data-tv-provider="${sourceId}:fox_one"]`); - await expect(fastFox).toContainText('FOX One'); + await expect(fastFox).toHaveAttribute('aria-label',/^FOX One,/); await expect(fastFox.locator('img')).toHaveAttribute('src',/\/assets\/providers\/tv-foxone\.ico$/); await fastFox.scrollIntoViewIfNeeded(); await expect.poll(()=>fastFox.locator('img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); diff --git a/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs b/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs new file mode 100644 index 00000000..02c9b125 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs @@ -0,0 +1,66 @@ +/** Verifies compact source navigation, direct guide playback, hold menus, and readable DVR dialogs. */ +import {test,expect} from '@playwright/test'; + +async function guide(page){ + const now=Math.floor(Date.now()/1000),start=Math.floor(now/1800)*1800; + const channels=Array.from({length:160},(_,i)=>{let provider='tcl';if(i<150){provider='pluto';}if(i<110){provider='espn';}return {id:i+1,source_id:i<60?1:2,source_key:String(i+1),guide_key:String(i+1),name:`Channel ${i+1}`,number:String(i+1),group_name:'',provider_id:i<60?undefined:provider,logo:`http://127.0.0.1:${process.env.KOKO_E2E_PORT}/assets/providers/tv-espn.png`,enabled:true,available:true};}); + const programs=channels.flatMap(channel=>[0,1].map(i=>({id:channel.id*10+i,source_id:channel.source_id,guide_key:channel.guide_key,title:`Show ${channel.id}${i?' next':''}`,starts_at:i?start+3600:start-600,ends_at:i?start+7200:start+3600,metadata:{description:'Program information',categories:['Sports'],image:`http://127.0.0.1:${process.env.KOKO_E2E_PORT}/assets/providers/tv-foxone.ico`,is_live:!i}}))); + const preferences={favorites:[],recent:[]},tunes=[],recordings=[],errors=[];page.on('pageerror',error=>errors.push(error.message));let failRecording=false; + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[],preferences,sources:[{id:1,name:'NextPVR',kind:'nextpvr'},{id:2,name:'FastChannels',kind:'m3u',iptv_mode:'fastchannels'}],providers:[{source_id:2,id:'espn',name:'ESPN',enabled:true,channel_count:50},{source_id:2,id:'pluto',name:'Pluto TV',enabled:true,channel_count:40},{source_id:2,id:'tcl',name:'TCL TV+',enabled:true,channel_count:10}]}})); + await page.route('**/api/v1/live-tv/recordings',route=>{ + if(route.request().method()==='POST'){recordings.push(route.request().postDataJSON());return route.fulfill(failRecording?{status:400,json:{status:400,message:'Recording fixture failure'}}:{json:{}});} + return route.fulfill({json:{recordings:[],conflicts:[],can_record:true}}); + }); + await page.route('**/api/v1/live-tv/rules',route=>route.fulfill({json:[]})); + await page.route('**/api/v1/live-tv/sessions',route=>{tunes.push(route.request().postDataJSON());return route.fulfill({status:400,json:{status:400,message:'Playback activation fixture'}});}); + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/live-tv');await expect(page.locator('#tv-guide-scroll')).toBeVisible(); + return {tunes,recordings,errors,failRecording:value=>{failRecording=value;}}; +} + +test('source and provider navigation uses large icons, clear nesting, and unclipped focus',async({page},testInfo)=>{ + await guide(page); + const source=page.getByRole('button',{name:'NextPVR, 60 channels',exact:true});await source.focus(); + const geometry=await source.evaluate(button=>{const ring=getComputedStyle(button),icon=button.querySelector('.tv-source-icon').getBoundingClientRect(),rail=button.closest('.tv-source-rail').getBoundingClientRect(),box=button.getBoundingClientRect();return {offset:parseFloat(ring.outlineOffset),icon:icon.width,left:box.left-rail.left,top:box.top-rail.top,text:button.textContent.trim()};}); + expect(geometry.offset).toBeLessThan(0);expect(geometry.icon).toBeGreaterThanOrEqual(60);expect(geometry.left).toBeGreaterThanOrEqual(0);expect(geometry.top).toBeGreaterThanOrEqual(0);expect(geometry.text).toBe('60'); + await page.screenshot({path:testInfo.outputPath('tv-source-focus.png')}); + await page.getByRole('button',{name:'FastChannels, 100 channels',exact:true}).click(); + const provider=page.getByRole('button',{name:'ESPN, 50 channels',exact:true});await page.keyboard.press('Tab');await provider.focus(); + const nested=await provider.evaluate(button=>{const source=document.querySelector('[data-tv-source="2"]').getBoundingClientRect(),box=button.getBoundingClientRect();return {left:box.left-source.left,width:button.querySelector('.tv-source-icon').getBoundingClientRect().width,offset:parseFloat(getComputedStyle(button).outlineOffset)};}); + expect(nested.left).toBeGreaterThan(8);expect(nested.width).toBeGreaterThanOrEqual(48);expect(nested.offset).toBeLessThan(0); + expect(await page.locator('.tv-channel-logo-slot').first().evaluate(icon=>icon.getBoundingClientRect().width)).toBeGreaterThanOrEqual(70); + const tcl=page.getByRole('button',{name:'TCL TV+, 10 channels',exact:true});await expect(tcl.locator('img')).toHaveAttribute('src',/tv-tcl\.png$/);await expect.poll(()=>tcl.locator('img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy();await provider.focus(); + await page.screenshot({path:testInfo.outputPath('tv-compact-navigation.png')}); + await tcl.focus();await page.screenshot({path:testInfo.outputPath('tv-tcl-provider.png')}); +}); + +test('guide click and Enter tune the selected channel while holds open recording options without tuning',async({page},testInfo)=>{ + const fixture=await guide(page); + const watch=async(selector,key)=>{ + const previous=fixture.tunes.length,button=page.locator(selector);await button.focus(); + if(key)await page.keyboard.press('Enter');else await button.click(); + await expect.poll(()=>fixture.tunes.length).toBe(previous+1); + expect(fixture.tunes.at(-1).channel_id).toBe(2);await expect(page.getByText('Playback activation fixture',{exact:true})).toBeVisible(); + await page.locator('#close-player').click();await page.getByRole('button',{name:'Stop playback',exact:true}).click(); + }; + await watch('#tv-channel-select-2',false);await watch('#tv-program-2-20',true); + await watch('#tv-program-2-20',false);await watch('#tv-channel-select-2',true); + const count=fixture.tunes.length,program=page.locator('#tv-program-2-20');await program.focus(); + await page.keyboard.down('Enter');await expect(program).toHaveClass(/is-holding/);await page.waitForTimeout(700); + const options=page.getByRole('dialog',{name:'Channel options',exact:true});await expect(options).toBeVisible();await page.keyboard.up('Enter');expect(fixture.tunes.length).toBe(count); + await options.getByRole('button',{name:'Record program or series',exact:true}).click(); + const dialog=page.getByRole('dialog',{name:'Record program or series',exact:true});await expect(dialog).toBeVisible();await expect(dialog).toContainText('Show 2'); + await dialog.getByLabel('Before (minutes)',{exact:true}).fill('4');await dialog.getByLabel('After (minutes)',{exact:true}).fill('7');await dialog.getByLabel('Private (only my account)',{exact:true}).check();await dialog.getByLabel('New episodes only (series)',{exact:true}).check(); + await page.locator('[data-tv-refresh]').evaluate(button=>button.click());await expect(dialog.getByLabel('Before (minutes)',{exact:true})).toHaveValue('4'); + await page.screenshot({path:testInfo.outputPath('tv-recording-modal.png')}); + fixture.failRecording(true);await dialog.getByRole('button',{name:'Record series',exact:true}).click();await expect(dialog.getByRole('alert')).toHaveText('Recording fixture failure');await expect(dialog.getByLabel('Before (minutes)',{exact:true})).toHaveValue('4'); + fixture.failRecording(false);await page.screenshot({path:testInfo.outputPath('tv-recording-error.png')});await dialog.getByRole('button',{name:'Record series',exact:true}).click();await expect(dialog).toHaveCount(0); + expect(fixture.recordings.at(-1)).toMatchObject({channel_id:2,title:'Show 2',series:true,new_only:true,private:true,padding_before:240,padding_after:420}); + await program.click({button:'right'});await expect(options).toBeVisible();await page.keyboard.press('Escape');await expect(options).toHaveCount(0);await expect(program).toBeFocused(); + const box=await program.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.waitForTimeout(700);await expect(options).toBeVisible();await page.mouse.up();expect(fixture.tunes.length).toBe(count);await page.keyboard.press('Escape'); + await page.locator('#tv-guide-scroll').evaluate(node=>{node.scrollTop=45*88;});await expect(page.locator('#tv-program-46-460')).toBeAttached();await page.locator('#tv-program-46-460').click({button:'right'});await expect(options).toContainText('Show 46');await page.keyboard.press('Escape'); + await page.setViewportSize({width:390,height:844});await page.locator('#tv-program-46-460').click({button:'right'});await options.getByRole('button',{name:'Record program or series',exact:true}).click();await expect(dialog).toBeVisible(); + const mobile=await dialog.boundingBox();expect(mobile.x).toBeGreaterThanOrEqual(0);expect(mobile.x+mobile.width).toBeLessThanOrEqual(390);await page.screenshot({path:testInfo.outputPath('tv-recording-modal-mobile.png')});await page.keyboard.press('Escape'); + expect(fixture.errors).toEqual([]); +}); diff --git a/crates/client-web/tests/e2e/live-tv.spec.mjs b/crates/client-web/tests/e2e/live-tv.spec.mjs index 61e0a5a6..e6729aaf 100644 --- a/crates/client-web/tests/e2e/live-tv.spec.mjs +++ b/crates/client-web/tests/e2e/live-tv.spec.mjs @@ -88,7 +88,7 @@ test('IPTV imports its guide, plays live video, releases the tuner, and saves a await page.screenshot({ path: testInfo.outputPath('live-tv-guide.png') }); const channels = await (await page.request.get('/api/v1/live-tv/channels', { headers })).json(); const hls = channels.find(c => c.source_id === sourceId && c.name === 'HLS Test TV'); - await page.locator(`[data-tv-play="${hls.id}"]`).first().click(); + await page.locator(`#tv-channel-select-${hls.id}`).click(); await expect.poll(() => page.locator('#media-player').evaluate(video => video.readyState >= 2 && video.currentTime > 0.1),{timeout:45000}).toBeTruthy(); await page.getByRole('button', { name:'Close player',exact:true }).click(); await expect(page.locator('.tv-preview-player #media-player')).toBeAttached(); @@ -113,7 +113,7 @@ test('IPTV imports its guide, plays live video, releases the tuner, and saves a await page.getByRole('button',{name:'Close player',exact:true}).click(); await page.getByRole('button',{name:'Stop playback',exact:true}).click(); await expect.poll(()=>activeStreams).toBe(0); - await page.locator(`[data-tv-play="${channel.id}"]`).first().click(); + await page.locator(`.tv-guide-program[data-tv-select-channel="${channel.id}"]`).first().click(); await expect.poll(() => page.locator('#media-player').evaluate(video => video.readyState >= 2 && video.currentTime > 0.2),{timeout:45000}).toBeTruthy(); const viewport = await page.locator('.media-player-overlay').boundingBox(); expect(viewport.x).toBe(0); expect(viewport.y).toBe(0); expect(viewport.height).toBe(900); @@ -421,11 +421,11 @@ test('source settings prefill connections, report imports, isolate channel edito await group.getByRole('button',{name:'Create group',exact:true}).click(); await expect(page.getByText('Combined FastChannels',{exact:true})).toBeVisible(); await page.goto('/live-tv'); - const tab=page.locator('[data-tv-source-group]').filter({hasText:'Combined FastChannels'}); + const tab=page.locator('[data-tv-source-group][aria-label^="Combined FastChannels,"]'); if(await tab.count()) await tab.click(); await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-channel-count','2'); const bridgeChannel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===ids[1]); - await page.locator(`[data-tv-select-channel="${bridgeChannel.id}"]`).first().click(); + await page.locator(`[data-tv-select-channel="${bridgeChannel.id}"]`).first().focus(); await expect(page.locator('#tv-guide-hero h2')).toHaveText('ESPN program'); await page.reload();await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-channel-count','2'); const saved=await(await page.request.get(`/api/v1/live-tv/sources/${ids[0]}/connection`,{headers})).json(); diff --git a/crates/client-web/tests/hold-actions.test.mjs b/crates/client-web/tests/hold-actions.test.mjs new file mode 100644 index 00000000..4d4a71df --- /dev/null +++ b/crates/client-web/tests/hold-actions.test.mjs @@ -0,0 +1,58 @@ +/** Exercises delegated guide holds, cancellation, release suppression, and virtualized controls. */ +import assert from 'node:assert/strict'; +import {after,test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {createServer} from 'vite'; +import {JSDOM} from 'jsdom'; + +const server=await createServer({root:fileURLToPath(new URL('..',import.meta.url)),server:{middlewareMode:true,hmr:false,ws:false}}); +after(()=>server.close()); +const {bindHoldActions}=await server.ssrLoadModule('/src/app/holdActions.ts'); + +function fixture(t){ + t.mock.timers.enable({apis:['setTimeout']}); + const dom=new JSDOM(''); + const previous={document:globalThis.document,addEventListener:globalThis.addEventListener,AbortController:globalThis.AbortController}; + globalThis.document=dom.window.document;globalThis.addEventListener=dom.window.addEventListener.bind(dom.window);globalThis.AbortController=dom.window.AbortController; + const controller=new dom.window.AbortController(),plays=[],menus=[]; + bindHoldActions('[data-guide]',button=>plays.push(button.textContent),button=>{menus.push(button.textContent);document.querySelector('#option').focus();},controller.signal); + const fire=(target,type,values={})=>{const Constructor=type.startsWith('key')?dom.window.KeyboardEvent:dom.window.MouseEvent;const event=new Constructor(type,{bubbles:true,cancelable:true,...values});target.dispatchEvent(event);return event;}; + t.after(()=>{controller.abort();dom.window.close();Object.assign(globalThis,previous);}); + return {dom,controller,plays,menus,fire,button:document.querySelector('[data-guide]')}; +} + +test('short clicks and Enter activate exactly once, while a hold consumes release on the focused option',t=>{ + const f=fixture(t);f.button.click(); + f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(100);f.fire(f.button,'keyup',{key:'Enter'}); + assert.equal(f.plays.length,2); + f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(650); + assert.equal(f.menus.length,1); + assert.equal(f.fire(document.activeElement,'keydown',{key:'Enter',repeat:true}).defaultPrevented,true); + assert.equal(f.fire(document.activeElement,'keyup',{key:'Enter'}).defaultPrevented,true); + assert.equal(f.plays.length,2); +}); + +test('pointer holds suppress their click and later clicks still activate newly mounted rows',t=>{ + const f=fixture(t);f.fire(f.button,'pointerdown',{clientX:10,clientY:10});t.mock.timers.tick(650); + f.fire(f.button,'pointerup');f.button.click();assert.equal(f.menus.length,1);assert.equal(f.plays.length,0); + f.button.outerHTML=''; + const next=document.querySelector('[data-guide]');f.fire(next,'pointerdown');f.fire(next,'pointerup');next.click(); + assert.deepEqual(f.plays,['Virtualized channel']); + f.controller.abort();next.click();assert.equal(f.plays.length,1); +}); + +test('scroll gestures and focus loss cancel pending holds without starting playback',t=>{ + const f=fixture(t);f.fire(f.button,'pointerdown',{clientX:10,clientY:10});f.fire(f.button,'pointermove',{clientX:10,clientY:40});t.mock.timers.tick(650);f.button.click(); + f.button.focus();f.fire(f.button,'keydown',{key:'Enter'});document.querySelector('#option').focus();t.mock.timers.tick(650);f.fire(document.activeElement,'keyup',{key:'Enter'}); + assert.equal(f.plays.length,0);assert.equal(f.menus.length,0); + f.fire(f.button,'contextmenu');assert.deepEqual(f.menus,['Channel']); +}); + +test('a guide refresh preserves a held key and uses the refreshed callbacks',t=>{ + const f=fixture(t);f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(300); + f.controller.abort(); + const next=new f.dom.window.AbortController();t.after(()=>next.abort()); + bindHoldActions('[data-guide]',()=>f.plays.push('refreshed'),()=>f.menus.push('refreshed'),next.signal); + t.mock.timers.tick(350);assert.deepEqual(f.menus,['refreshed']); + f.fire(f.button,'keyup',{key:'Enter'});assert.equal(f.plays.length,0); +}); diff --git a/crates/client-web/tests/live-tv.test.mjs b/crates/client-web/tests/live-tv.test.mjs index 8ebf1e34..a3f35b47 100644 --- a/crates/client-web/tests/live-tv.test.mjs +++ b/crates/client-web/tests/live-tv.test.mjs @@ -294,7 +294,7 @@ test('broadcast metadata is escaped and active artwork URLs never become image e assert.equal(dom.window.document.querySelector('.tv-guide-hero'),null); dom.window.close(); assert.match(html, /<script>/); assert.match(html, /<img/); assert.doesNotMatch(html, /javascript:|', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); + state.liveTv = data({ programs: [{ ...program, title: '', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); const html = renderLiveTvPage(); const dom = new JSDOM(html); assert.ok(dom.window.document.querySelector('.home-pinned-header > .home-feature > .home-feature-copy')); assert.ok(dom.window.document.querySelector('.home-navbar .browse-tabs')); assert.equal(dom.window.document.querySelector('.tv-guide-hero'),null); + assert.equal(dom.window.document.querySelector('script, img[src="x"]'),null); dom.window.close(); - assert.match(html, /<script>/); assert.match(html, /<img/); assert.doesNotMatch(html, /javascript:|