Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76f8a939c | ||
|
|
a5ac63dbd9 | ||
|
|
9741ce7d6a | ||
|
|
7ef7ef25e8 | ||
|
|
8e82673e6b | ||
|
|
33ba3a0492 | ||
|
|
181eabdded | ||
|
|
1131ecacfb | ||
|
|
81684411bd | ||
|
|
11d3ba3823 | ||
|
|
dc5d889ccb | ||
|
|
6a4d81f862 | ||
|
|
13584f5daf | ||
|
|
7a01ad62bd | ||
|
|
238937fa5b | ||
|
|
e7072e49a8 | ||
|
|
a9d9fc8afb | ||
|
|
89c7d08cde | ||
|
|
1f4341a347 | ||
|
|
9a7ac28f9f | ||
|
|
331a427040 | ||
|
|
630d78a804 | ||
|
|
938e4e0f50 | ||
|
|
dbd4bb43f7 | ||
|
|
39b07e178d | ||
|
|
f31476e012 | ||
|
|
0320735f47 | ||
|
|
a2c056dd7a | ||
|
|
e9e701f621 | ||
|
|
4a7bd0b24c | ||
|
|
0d57cb6c0d | ||
|
|
456c2c7c84 | ||
|
|
9605f9ea65 | ||
|
|
2352604836 | ||
|
|
35ad7df6a4 | ||
|
|
b17a70be1b | ||
|
|
1575b9b537 | ||
|
|
10ae885b47 | ||
|
|
8f90cea5cc | ||
|
|
9938d9a6cd | ||
|
|
dd273fbbb2 | ||
|
|
27d01db265 | ||
|
|
3bc8192d3b | ||
|
|
de16a7c066 | ||
|
|
be85a462a4 | ||
|
|
95d58f3b69 | ||
|
|
3654fad163 | ||
|
|
9f0bb65fb4 | ||
|
|
72d47ef052 | ||
|
|
e78aa01c05 | ||
|
|
0c6e8fab01 | ||
|
|
fe4706474e | ||
|
|
a886fd2824 | ||
|
|
489956b167 | ||
|
|
2090f7865c | ||
|
|
5c1d2f63fa | ||
|
|
e8580f4a84 | ||
|
|
ca5ac94466 | ||
|
|
d1a52eaa8b | ||
|
|
7dee5194f4 | ||
|
|
ee044ab271 | ||
|
|
6532929cf2 | ||
|
|
0f0a0d7fe5 | ||
|
|
572066157e | ||
|
|
b39c3b7bb0 | ||
|
|
0d7803079c | ||
|
|
82467f188b | ||
|
|
947c8aacd8 | ||
|
|
bd84593cd1 | ||
|
|
805ca026c4 | ||
|
|
44f4db5a6d | ||
|
|
30565145cd | ||
|
|
ce5aaa8706 | ||
|
|
0416c00f96 | ||
|
|
3876809a83 | ||
|
|
e9fe12d1fd | ||
|
|
bceffef856 | ||
|
|
0f76860519 | ||
|
|
8e5075e419 |
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# design-sync-reminder — single hook, both directions (#388). Keeps the Claude Design project
|
||||
# (`ChicoryTV Design System`, eb3b6122 / local mirror `design-system/`) in step with the shipped
|
||||
# SPA. Trigger is PURELY MECHANICAL: "touching the UI" == a file matching UI_RE below. No prompt
|
||||
# keyword guessing. Wired to two boundaries:
|
||||
#
|
||||
# start (PreToolUse / Write|Edit) — the FIRST time this session edits a UI file, remind to PULL
|
||||
# the current design from Claude Design first.
|
||||
# finish (Stop) — if the working tree actually changed a UI file, remind to
|
||||
# MIRROR/PUSH the change back before wrapping up.
|
||||
#
|
||||
# UI_RE is the one place the "what counts as UI" fileset is defined: SPA .tsx/.css under web/src
|
||||
# (test files excluded). Widen it here if the design surface grows.
|
||||
#
|
||||
# Fail-open: any parse trouble / non-match → emit nothing, exit 0. Throttled once per session per
|
||||
# phase so it informs without nagging. DesignSync runs only from the main session (docs/design-sync.md).
|
||||
# This is a reminder, never a hard gate — `start` only injects context; `finish` is a one-shot Stop nudge.
|
||||
set -euo pipefail
|
||||
|
||||
UI_RE='(^|/)web/src/.*\.(tsx|css)$'
|
||||
TEST_RE='\.test\.(tsx|ts)$'
|
||||
|
||||
phase="${1:-}"
|
||||
input=$(cat)
|
||||
me=$(printf '%s' "$input" | jq -r '.session_id // "nosess"' 2>/dev/null || true)
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || true)
|
||||
[ -z "$cwd" ] && cwd="$PWD"
|
||||
marker="${TMPDIR:-/tmp}/ctv-designsync-${phase}-${me}"
|
||||
|
||||
case "$phase" in
|
||||
start)
|
||||
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""' 2>/dev/null || true)
|
||||
[ -z "$fp" ] && exit 0
|
||||
printf '%s' "$fp" | grep -qE "$TEST_RE" && exit 0 # skip test files
|
||||
printf '%s' "$fp" | grep -qE "$UI_RE" || exit 0 # not a UI file → nothing
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
read -r -d '' MSG <<'EOF' || true
|
||||
[design-sync #388] About to edit a ChicoryTV SPA UI file. The `design-system/` prototypes mirror the Claude Design project (eb3b6122). If you're changing how a screen LOOKS, first PULL its current prototype from Claude Design so you start from the live design (docs/design-sync.md, pull = DesignSync list_files/get_file → design-system/, incremental). You'll be reminded to MIRROR the change back when the task finishes. DesignSync runs only from the main session.
|
||||
EOF
|
||||
jq -n --arg m "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}'
|
||||
exit 0
|
||||
;;
|
||||
finish)
|
||||
# Did this turn actually change a UI file? (tracked diff vs HEAD + untracked, minus tests)
|
||||
changed=$( { git -C "$cwd" diff --name-only HEAD 2>/dev/null; git -C "$cwd" ls-files --others --exclude-standard 2>/dev/null; } | grep -vE "$TEST_RE" | grep -E "$UI_RE" || true )
|
||||
[ -z "$changed" ] && exit 0
|
||||
[ -f "$marker" ] && exit 0
|
||||
: > "$marker" 2>/dev/null || true
|
||||
n=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
reason="[design-sync #388] This task changed ${n} SPA UI file(s) under web/src. Before wrapping up, MIRROR the visual change into the matching design-system/templates/chicorytv-admin/*.jsx prototype and push it to Claude Design (eb3b6122) in this same session, per docs/design-sync.md — so the design system does not drift from prod. If you already synced, or are deliberately deferring the mirror (say why), just note it and stop. DesignSync runs only from the main session. This one-shot reminder won't fire again this session."
|
||||
jq -n --arg r "$reason" '{decision:"block",reason:$r}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -46,6 +46,16 @@
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" start",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -59,6 +69,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/design-sync-reminder.sh\" finish",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.0.2",
|
||||
"version": "2025.3.4.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
+9
-5
@@ -106,13 +106,17 @@ ij_json_wrap_long_lines = false
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
|
||||
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
|
||||
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
|
||||
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
|
||||
# severities (e.g. ca1848 above) still take precedence over this bulk default.
|
||||
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
|
||||
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
|
||||
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
|
||||
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
|
||||
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# A collection count can never be negative. Treat comparisons that therefore collapse to a
|
||||
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
|
||||
dotnet_diagnostic.S3981.severity = warning
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
|
||||
@@ -101,7 +101,40 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
|
||||
# floor later"), so this step is purely informational — continue-on-error keeps a missing
|
||||
# report or a transient tool-install failure from ever blocking a build.
|
||||
- name: Coverage summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
reports=(coverage/**/coverage.cobertura.xml)
|
||||
if [ ${#reports[@]} -eq 0 ]; then
|
||||
echo "No coverage reports found under ./coverage -- skipping summary."
|
||||
exit 0
|
||||
fi
|
||||
echo "Found ${#reports[@]} coverage report(s)."
|
||||
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
|
||||
# is already present under set -e); pinned for reproducible summary output.
|
||||
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
"-reporttypes:TextSummary;MarkdownSummaryGithub"
|
||||
echo "::group::Coverage summary"
|
||||
cat coverage/report/Summary.txt
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
@@ -202,6 +235,72 @@ jobs:
|
||||
done
|
||||
echo "::endgroup::"
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
|
||||
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
|
||||
# the unit-test gate — promote it to a required check / build dependency once it's proven
|
||||
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
|
||||
# Runs on PRs and on main (regression net); skipped for v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
- name: Ensure ffmpeg is available
|
||||
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
CFG="$(mktemp -d)"
|
||||
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
|
||||
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
|
||||
OUT="$(scripts/e2e-local.sh "$CFG")"
|
||||
printf '%s\n' "$OUT"
|
||||
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
|
||||
/coverage/
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
|
||||
+13
-2
@@ -3,6 +3,12 @@
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
|
||||
threading analyzer in every centrally managed project. The checked-in globalconfig
|
||||
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-All</AnalysisLevel>
|
||||
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
@@ -10,8 +16,13 @@
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
|
||||
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+8
-10
@@ -1,9 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
|
||||
versions, does not inherit a versionless analyzer PackageReference. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
@@ -12,11 +10,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
|
||||
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
|
||||
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
|
||||
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
|
||||
rules are promoted to warning/error incrementally. -->
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
|
||||
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
|
||||
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
|
||||
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
|
||||
high-value rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneAxisMap
|
||||
{
|
||||
// Server-owned Lucene smart-collection query for an axis value.
|
||||
public static string GenerateQuery(AutoTuneAxis axis, string value)
|
||||
{
|
||||
string escaped = EscapeLuceneValue(value);
|
||||
return axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
|
||||
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
|
||||
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
}
|
||||
|
||||
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
|
||||
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
|
||||
public static string GenerateName(AutoTuneAxis axis, string value) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => value,
|
||||
AutoTuneAxis.TvGenre => value,
|
||||
AutoTuneAxis.MovieGenre => $"{value} Movies",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
|
||||
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
|
||||
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
|
||||
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
||||
};
|
||||
|
||||
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
||||
public static string EscapeLuceneValue(string value) =>
|
||||
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public static class AutoTuneNumberAllocator
|
||||
{
|
||||
// Allocate `count` sequential integer channel numbers starting at `startingNumber`,
|
||||
// skipping any number already present in `existingNumbers`. Channel.Number is a string,
|
||||
// so numbers are returned as invariant-culture strings.
|
||||
public static List<string> Allocate(int startingNumber, int count, ISet<string> existingNumbers)
|
||||
{
|
||||
var result = new List<string>(count);
|
||||
int next = startingNumber;
|
||||
while (result.Count < count)
|
||||
{
|
||||
string candidate = next.ToString(CultureInfo.InvariantCulture);
|
||||
if (!existingNumbers.Contains(candidate))
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
|
||||
next++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateAutoTunedChannels(
|
||||
int TemplateId,
|
||||
string Group,
|
||||
List<AutoTuneChannelSelection> Channels) : IRequest<AutoTuneResult>;
|
||||
|
||||
public record AutoTuneChannelSelection(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number);
|
||||
|
||||
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
|
||||
{
|
||||
public int CreatedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Created);
|
||||
public int SkippedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Skipped);
|
||||
public int FailedCount => Results.Count(r => r.Status == AutoTuneOutcomeStatus.Failed);
|
||||
}
|
||||
|
||||
public record AutoTuneChannelOutcome(
|
||||
string Name,
|
||||
AutoTuneOutcomeStatus Status,
|
||||
int? ChannelId,
|
||||
string Reason);
|
||||
|
||||
public enum AutoTuneOutcomeStatus
|
||||
{
|
||||
Created,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Api.LibraryBrowse;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateAutoTunedChannelsHandler(ISender mediator)
|
||||
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
|
||||
{
|
||||
private const string NumberTakenError = "Channel number must be unique";
|
||||
private const string DefaultGroup = "Auto-Tuned";
|
||||
|
||||
public async Task<AutoTuneResult> Handle(
|
||||
CreateAutoTunedChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
|
||||
var outcomes = new List<AutoTuneChannelOutcome>();
|
||||
|
||||
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
|
||||
{
|
||||
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
|
||||
}
|
||||
|
||||
return new AutoTuneResult(outcomes);
|
||||
}
|
||||
|
||||
private async Task<AutoTuneChannelOutcome> CreateOne(
|
||||
int templateId,
|
||||
string group,
|
||||
AutoTuneChannelSelection selection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string name = (selection.Name ?? string.Empty).Trim();
|
||||
if (name.Length is 0 or > 50)
|
||||
{
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
|
||||
}
|
||||
|
||||
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
|
||||
PlaybackOrder order = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||||
|
||||
// 1. Create the smart collection that drives this channel.
|
||||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||||
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
|
||||
|
||||
SmartCollectionViewModel smartCollection = null;
|
||||
foreach (BaseError error in scResult.LeftToSeq())
|
||||
{
|
||||
return new AutoTuneChannelOutcome(
|
||||
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
|
||||
}
|
||||
|
||||
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
|
||||
{
|
||||
smartCollection = vm;
|
||||
}
|
||||
|
||||
// 2. Create the channel from a single-item lineup referencing the smart collection.
|
||||
var command = new CreateChannelFromLineup(
|
||||
name,
|
||||
selection.Number,
|
||||
group,
|
||||
string.Empty,
|
||||
ArtworkContentTypeModel.None,
|
||||
IsEnabled: true,
|
||||
ShowInEpg: true,
|
||||
templateId,
|
||||
new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: order),
|
||||
[
|
||||
new CreateChannelFromLineupItem(
|
||||
LibraryBrowseMediaType.SmartCollection,
|
||||
CollectionType.SmartCollection,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: smartCollection.Id,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null)
|
||||
]);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
foreach (BaseError error in channelResult.LeftToSeq())
|
||||
{
|
||||
// Roll back the smart collection we just created so a retry of this
|
||||
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
|
||||
// the primary outcome below is still Skipped/Failed regardless of the delete result.
|
||||
// Swallow any exception (not just an Either.Left) so a transient infra failure
|
||||
// during rollback never aborts this channel's outcome or the batch; the
|
||||
// orphaned SmartCollection is an acceptable degraded outcome.
|
||||
try
|
||||
{
|
||||
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// intentionally ignored; see comment above
|
||||
}
|
||||
|
||||
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
|
||||
? AutoTuneOutcomeStatus.Skipped
|
||||
: AutoTuneOutcomeStatus.Failed;
|
||||
return new AutoTuneChannelOutcome(name, status, null, error.Value);
|
||||
}
|
||||
|
||||
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
|
||||
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record PreviewAutoTuneChannels(
|
||||
List<AutoTuneAxis> Axes,
|
||||
int MinItems,
|
||||
int StartingNumber) : IRequest<Either<BaseError, List<AutoTuneProposal>>>;
|
||||
|
||||
public record AutoTuneProposal(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number,
|
||||
int ItemCount,
|
||||
bool AlreadyExists);
|
||||
@@ -0,0 +1,144 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class PreviewAutoTuneChannelsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<PreviewAutoTuneChannels, Either<BaseError, List<AutoTuneProposal>>>
|
||||
{
|
||||
public async Task<Either<BaseError, List<AutoTuneProposal>>> Handle(
|
||||
PreviewAutoTuneChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Axes is null || request.Axes.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one axis is required");
|
||||
}
|
||||
|
||||
if (request.MinItems < 1)
|
||||
{
|
||||
return BaseError.New("Minimum items must be at least 1");
|
||||
}
|
||||
|
||||
if (request.StartingNumber < 1)
|
||||
{
|
||||
return BaseError.New("Starting channel number must be at least 1");
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Enumerate (axis, value, count) triples per requested axis, preserving axis order.
|
||||
var raw = new List<(AutoTuneAxis Axis, string Value, int Count)>();
|
||||
foreach (AutoTuneAxis axis in request.Axes.Distinct())
|
||||
{
|
||||
raw.AddRange(await EnumerateAxis(dbContext, axis, request.MinItems, cancellationToken));
|
||||
}
|
||||
|
||||
System.Collections.Generic.HashSet<string> existingNumbers = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Number).ToListAsync(cancellationToken))
|
||||
.ToHashSet();
|
||||
System.Collections.Generic.HashSet<string> existingNames = (await dbContext.Channels.AsNoTracking()
|
||||
.Select(c => c.Name).ToListAsync(cancellationToken))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Drop entries whose generated name would be rejected at create time (Channel name <= 50
|
||||
// chars) before number allocation, so numbers aren't wasted on proposals that can never
|
||||
// be created.
|
||||
List<(AutoTuneAxis Axis, string Value, int Count, string Name)> survivors = raw
|
||||
.Select(r => (r.Axis, r.Value, r.Count, Name: AutoTuneAxisMap.GenerateName(r.Axis, r.Value)))
|
||||
.Where(r => r.Name.Length <= 50)
|
||||
.ToList();
|
||||
|
||||
List<string> numbers = AutoTuneNumberAllocator.Allocate(
|
||||
request.StartingNumber, survivors.Count, existingNumbers);
|
||||
|
||||
var proposals = new List<AutoTuneProposal>(survivors.Count);
|
||||
for (int i = 0; i < survivors.Count; i++)
|
||||
{
|
||||
(AutoTuneAxis axis, string value, int count, string name) = survivors[i];
|
||||
proposals.Add(new AutoTuneProposal(
|
||||
axis, value, name, numbers[i], count, existingNames.Contains(name)));
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateAxis(
|
||||
TvContext dbContext, AutoTuneAxis axis, int minItems, CancellationToken cancellationToken) =>
|
||||
axis switch
|
||||
{
|
||||
AutoTuneAxis.TvShow => await EnumerateTvShows(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.TvGenre => await EnumerateEpisodeGenres(dbContext, minItems, cancellationToken),
|
||||
AutoTuneAxis.MovieGenre => await EnumerateMovieGenres(dbContext, minItems, cancellationToken),
|
||||
_ => []
|
||||
};
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateTvShows(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
// Episode count per show id (Episode -> Season -> ShowId). Proven query style from LibraryBrowseItemMapper.
|
||||
Dictionary<int, int> episodeCounts = await dbContext.Episodes.AsNoTracking()
|
||||
.GroupBy(e => e.Season.ShowId)
|
||||
.Select(g => new { ShowId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
|
||||
|
||||
var showTitles = await dbContext.ShowMetadata.AsNoTracking()
|
||||
.Select(sm => new { sm.ShowId, sm.Title })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Collapse shows that share a title (the generated show_title query matches them together).
|
||||
var byTitle = new Dictionary<string, int>();
|
||||
foreach (var row in showTitles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Title))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
episodeCounts.TryGetValue(row.ShowId, out int count);
|
||||
byTitle[row.Title] = byTitle.GetValueOrDefault(row.Title) + count;
|
||||
}
|
||||
|
||||
return byTitle
|
||||
.Where(kv => kv.Value >= minItems)
|
||||
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (AutoTuneAxis.TvShow, kv.Key, kv.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateEpisodeGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.EpisodeMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.TvGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<(AutoTuneAxis, string, int)>> EnumerateMovieGenres(
|
||||
TvContext dbContext, int minItems, CancellationToken cancellationToken)
|
||||
{
|
||||
var counts = await dbContext.MovieMetadata.AsNoTracking()
|
||||
.SelectMany(m => m.Genres)
|
||||
.GroupBy(g => g.Name)
|
||||
.Select(grp => new { Name = grp.Key, Count = grp.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return counts
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Count >= minItems)
|
||||
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(c => (AutoTuneAxis.MovieGenre, c.Name, c.Count))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,6 @@ public class GetChannelGuideDataHandler(
|
||||
|
||||
responseChannels.Add(
|
||||
new ChannelGuideChannelResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
programmes.OrderBy(p => p.Start).ToList()));
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -15,7 +18,8 @@ public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
@@ -23,6 +27,21 @@ public partial class GetChannelGuideHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
|
||||
// emitted as &), so the substituted base must be XML-escaped. A path prefix can legally
|
||||
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
|
||||
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
|
||||
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -48,7 +67,7 @@ public partial class GetChannelGuideHandler(
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
@@ -70,7 +89,7 @@ public partial class GetChannelGuideHandler(
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaybackSource(int ChannelId, DateTimeOffset At)
|
||||
: IRequest<Option<ChannelPlaybackSourceResponseModel>>;
|
||||
@@ -1,171 +0,0 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the physical playout item at a point in time without invoking the streaming or FFmpeg pipeline.
|
||||
/// Guide projection is deliberately not used here: guide entries may merge filler or split a block differently
|
||||
/// from the actual media-item boundaries a player must follow.
|
||||
/// </summary>
|
||||
public class GetChannelPlaybackSourceHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelPlaybackSource, Option<ChannelPlaybackSourceResponseModel>>
|
||||
{
|
||||
public async Task<Option<ChannelPlaybackSourceResponseModel>> Handle(
|
||||
GetChannelPlaybackSource request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Channel? channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
if (channel is null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Deleting a mirror's source sets this nullable FK to null. Do not self-resolve to a stale
|
||||
// playout that may remain attached to the mirror channel.
|
||||
if (channel.PlayoutSource == ChannelPlayoutSource.Mirror && channel.MirrorSourceChannelId is null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
int sourceChannelId = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
? channel.MirrorSourceChannelId!.Value
|
||||
: channel.Id;
|
||||
TimeSpan playoutOffset = channel.PlayoutSource == ChannelPlayoutSource.Mirror
|
||||
? channel.PlayoutOffset ?? TimeSpan.Zero
|
||||
: TimeSpan.Zero;
|
||||
DateTime sourceAtUtc = request.At.UtcDateTime - playoutOffset;
|
||||
|
||||
PlayoutItem? active = await ActiveItems(dbContext, sourceChannelId, sourceAtUtc)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
DateTime? nextSourceTransition = active?.Finish;
|
||||
if (nextSourceTransition is null)
|
||||
{
|
||||
nextSourceTransition = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.Playout.ChannelId == sourceChannelId && pi.Start > sourceAtUtc)
|
||||
.OrderBy(pi => pi.Start)
|
||||
.Select(pi => (DateTime?)pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
DateTimeOffset resolvedAt = request.At.ToUniversalTime();
|
||||
DateTimeOffset sourceAt = new(sourceAtUtc, TimeSpan.Zero);
|
||||
DateTimeOffset? nextTransitionAt = nextSourceTransition.HasValue
|
||||
? new DateTimeOffset(nextSourceTransition.Value + playoutOffset, TimeSpan.Zero)
|
||||
: null;
|
||||
|
||||
ChannelPlaybackItemResponseModel? playbackItem = active is null
|
||||
? null
|
||||
: ToPlaybackItem(active, sourceAtUtc, playoutOffset);
|
||||
|
||||
return new ChannelPlaybackSourceResponseModel(
|
||||
channel.Id,
|
||||
sourceChannelId,
|
||||
resolvedAt,
|
||||
sourceAt,
|
||||
nextTransitionAt,
|
||||
playbackItem);
|
||||
}
|
||||
|
||||
private static IQueryable<PlayoutItem> ActiveItems(TvContext dbContext, int sourceChannelId, DateTime sourceAtUtc) =>
|
||||
dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.Playout.ChannelId == sourceChannelId)
|
||||
.Where(pi => pi.Start <= sourceAtUtc && pi.Finish > sourceAtUtc)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream)!.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.AsSplitQuery();
|
||||
|
||||
private static ChannelPlaybackItemResponseModel ToPlaybackItem(
|
||||
PlayoutItem item,
|
||||
DateTime sourceAtUtc,
|
||||
TimeSpan playoutOffset)
|
||||
{
|
||||
TimeSpan currentOffset = item.InPoint + (sourceAtUtc - item.Start);
|
||||
if (currentOffset < item.InPoint)
|
||||
{
|
||||
currentOffset = item.InPoint;
|
||||
}
|
||||
|
||||
if (item.OutPoint > item.InPoint && currentOffset > item.OutPoint)
|
||||
{
|
||||
currentOffset = item.OutPoint;
|
||||
}
|
||||
|
||||
return new ChannelPlaybackItemResponseModel(
|
||||
item.Id,
|
||||
item.MediaItemId,
|
||||
new DateTimeOffset(item.Start + playoutOffset, TimeSpan.Zero),
|
||||
new DateTimeOffset(item.Finish + playoutOffset, TimeSpan.Zero),
|
||||
item.InPoint.Ticks,
|
||||
currentOffset.Ticks,
|
||||
item.OutPoint.Ticks,
|
||||
item.FillerKind,
|
||||
GetSourceReference(item.MediaItem));
|
||||
}
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel GetSourceReference(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
JellyfinMovie movie => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: movie.ItemId),
|
||||
JellyfinEpisode episode => Reference(ChannelPlaybackSourceKind.JellyfinItem, itemId: episode.ItemId),
|
||||
PlexMovie movie => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: movie.Key),
|
||||
PlexEpisode episode => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: episode.Key),
|
||||
PlexOtherVideo video => Reference(ChannelPlaybackSourceKind.PlexItem, itemId: video.Key),
|
||||
EmbyMovie movie => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: movie.ItemId),
|
||||
EmbyEpisode episode => Reference(ChannelPlaybackSourceKind.EmbyItem, itemId: episode.ItemId),
|
||||
RemoteStream stream => Reference(ChannelPlaybackSourceKind.RemoteUrl, isLive: stream.IsLive),
|
||||
Movie movie => LocalFile(movie.MediaVersions),
|
||||
Episode episode => LocalFile(episode.MediaVersions),
|
||||
MusicVideo video => LocalFile(video.MediaVersions),
|
||||
OtherVideo video => LocalFile(video.MediaVersions),
|
||||
Song song => LocalFile(song.MediaVersions),
|
||||
Image image => LocalFile(image.MediaVersions),
|
||||
_ => Reference(ChannelPlaybackSourceKind.Unsupported)
|
||||
};
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel LocalFile(IEnumerable<MediaVersion> versions)
|
||||
{
|
||||
string? path = versions.FirstOrDefault()?.MediaFiles.FirstOrDefault()?.Path;
|
||||
return string.IsNullOrWhiteSpace(path)
|
||||
? Reference(ChannelPlaybackSourceKind.Unsupported)
|
||||
: Reference(ChannelPlaybackSourceKind.LocalFile, path: path);
|
||||
}
|
||||
|
||||
private static ChannelPlaybackSourceReferenceResponseModel Reference(
|
||||
ChannelPlaybackSourceKind kind,
|
||||
string? itemId = null,
|
||||
string? path = null,
|
||||
bool isLive = false) =>
|
||||
new(kind, itemId, path, isLive);
|
||||
}
|
||||
@@ -4,19 +4,31 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
public class GetChannelPlaylistHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
|
||||
return new ChannelPlaylist(
|
||||
scheme,
|
||||
host,
|
||||
baseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken);
|
||||
}
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
@@ -10,7 +11,8 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
@@ -141,7 +143,8 @@ public class GetChannelStatesForApiHandler(
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
ffmpegSegmenterService.IsActive(channel.Number) ||
|
||||
directStreamSessionTracker.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,51 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateIptvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateIptvSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Unit> validation = Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim();
|
||||
|
||||
// A blank value clears the setting so the request-derived behavior is restored.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Unit> Validate(UpdateIptvSettings request)
|
||||
{
|
||||
string baseUrl = request.IptvSettings.BaseUrl;
|
||||
|
||||
// Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return AdvertisedBaseUrl.TryParse(baseUrl)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>(
|
||||
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class IptvSettingsViewModel
|
||||
{
|
||||
public string BaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
|
||||
{
|
||||
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
return new IptvSettingsViewModel
|
||||
{
|
||||
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count > 0 || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReshufflePlayout(int PlayoutId) : IRequest;
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReshufflePlayoutHandler(
|
||||
IMediator mediator,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ReshufflePlayout>
|
||||
{
|
||||
public async Task Handle(ReshufflePlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Where(p => p.ScheduleKind == PlayoutScheduleKind.Classic ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// Roll a new play order. BuildPlayout(Reset) only reseeds Playout.Seed for CLASSIC playouts
|
||||
// (PlayoutBuilder); Block/Sequential/Scripted rebuild deterministically from the existing seed.
|
||||
// ErasePlayoutHistory is the one primitive that reseeds + clears the derived per-collection
|
||||
// enumerator anchors for ALL four kinds — run it first, then rebuild from scratch.
|
||||
await mediator.Send(new ErasePlayoutHistory(playout.Id), cancellationToken);
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playout.Id, PlayoutBuildMode.Reset),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,7 +65,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -57,7 +57,8 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.CommandLine.Parsing;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
@@ -60,7 +60,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -65,7 +65,8 @@ public class
|
||||
playout.BuildStatus,
|
||||
playout.DecoId,
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
@@ -20,7 +20,8 @@ internal static class Mapper
|
||||
// the paged-playouts query does not eager-load Deco (the list response does not surface
|
||||
// the default deco); GetPlayoutById includes it for the detail response
|
||||
playout.Deco?.Name,
|
||||
playout.Version);
|
||||
playout.Version,
|
||||
playout.Seed);
|
||||
|
||||
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
@@ -14,7 +14,8 @@ public record PlayoutNameViewModel(
|
||||
PlayoutBuildStatus BuildStatus,
|
||||
int? DecoId,
|
||||
string DecoName,
|
||||
int Version)
|
||||
int Version,
|
||||
int Seed)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -31,6 +31,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
p.BuildStatus,
|
||||
p.DecoId,
|
||||
p.DecoId == null ? null : p.Deco.Name,
|
||||
p.Version));
|
||||
p.Version,
|
||||
p.Seed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class AdvertisedBaseUrlTests
|
||||
{
|
||||
private const string RequestScheme = "http";
|
||||
private const string RequestHost = "ersatztv:8409";
|
||||
private const string RequestBaseUrl = "";
|
||||
|
||||
[Test]
|
||||
public void Resolve_Uses_Configured_Value_When_Set()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"https://tv.example.com",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe("https");
|
||||
host.ShouldBe("tv.example.com");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Resolve_Falls_Back_To_Request_When_Blank(string configured)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
configured,
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_Falls_Back_To_Request_When_Invalid()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"not a url",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Non_Default_Port()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse("http://192.168.1.99:8409").IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe("http");
|
||||
host.ShouldBe("192.168.1.99:8409");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase("http://tv.example.com:80", "http", "tv.example.com")]
|
||||
[TestCase("https://tv.example.com:443", "https", "tv.example.com")]
|
||||
public void TryParse_Drops_Redundant_Default_Port(string configured, string expectedScheme, string expectedHost)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe(expectedScheme);
|
||||
host.ShouldBe(expectedHost);
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Path_Prefix()
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse("https://tv.example.com/etv").IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[TestCase("https://tv.example.com/", "")]
|
||||
[TestCase("https://tv.example.com/etv/", "/etv")]
|
||||
[TestCase("https://tv.example.com/etv//", "/etv")]
|
||||
public void TryParse_Normalizes_Trailing_Slash(string configured, string expectedBaseUrl)
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe(expectedBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Trims_Whitespace()
|
||||
{
|
||||
Option<(string Scheme, string Host, string BaseUrl)> result =
|
||||
AdvertisedBaseUrl.TryParse(" https://tv.example.com/etv ");
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(("", "", "")).BaseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Handles_IPv6_Host()
|
||||
{
|
||||
(string _, string host, string _) = AdvertisedBaseUrl.TryParse("http://[2001:db8::1]:8409").IfNone(("", "", ""));
|
||||
host.ShouldBe("[2001:db8::1]:8409");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase("not a url")]
|
||||
[TestCase("tv.example.com")] // no scheme (relative)
|
||||
[TestCase("//tv.example.com")] // protocol-relative (not absolute)
|
||||
[TestCase("ftp://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("ws://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("http://user:pass@tv.example.com")] // credentials
|
||||
[TestCase("http://tv.example.com?foo=bar")] // query
|
||||
[TestCase("http://tv.example.com/etv?foo=bar")] // query on a path
|
||||
[TestCase("http://tv.example.com#frag")] // fragment
|
||||
[TestCase("http://")] // no host
|
||||
public void TryParse_Rejects_Invalid_Input(string configured) =>
|
||||
AdvertisedBaseUrl.TryParse(configured).IsNone.ShouldBeTrue();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -104,6 +105,88 @@ public class ChannelGuideGoldenTests
|
||||
public Task Guide_with_base_url() =>
|
||||
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
|
||||
|
||||
// When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the
|
||||
// request-derived scheme/host — proving the override reaches both fragment substitution sites.
|
||||
[Test]
|
||||
public async Task Guide_uses_advertised_base_url_when_configured()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://public.example.com/etv"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The advertised origin replaces {RequestBase} on both the channel <icon> and the programme <icon>.
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg");
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg");
|
||||
xml.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
|
||||
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
|
||||
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_advertised_base_url()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The '&' from the base URL must be emitted as '&', never a bare '&'.
|
||||
xml.ShouldContain("https://tv.example.com/a&b/iptv/logos/news.jpg");
|
||||
xml.ShouldNotContain("a&b");
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(string goldenName, GetChannelGuide request)
|
||||
@@ -123,11 +206,19 @@ public class ChannelGuideGoldenTests
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
// No advertised base URL configured — the {RequestBase} substitution must use the request-derived
|
||||
// scheme/host/base, keeping today's output byte-for-byte identical (issue #340).
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem);
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelPlaylistHandlerTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
|
||||
[Test]
|
||||
public async Task Uses_Advertised_Base_Url_When_Configured()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: "https://public.example.com/etv");
|
||||
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Falls_Back_To_Request_When_Unset()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: null);
|
||||
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain("public.example.com");
|
||||
}
|
||||
|
||||
private static async Task<string> BuildM3U(string configuredBaseUrl)
|
||||
{
|
||||
var channelRepository = Substitute.For<IChannelRepository>();
|
||||
channelRepository.GetAll(Arg.Any<CancellationToken>()).Returns([BuildChannel()]);
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Optional(configuredBaseUrl));
|
||||
|
||||
var handler = new GetChannelPlaylistHandler(channelRepository, configElementRepository);
|
||||
|
||||
ChannelPlaylist playlist = await handler.Handle(
|
||||
new GetChannelPlaylist(Scheme, Host, BaseUrl: "", Mode: "mixed", UserAgent: "VLC/3.0", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
return playlist.ToM3U();
|
||||
}
|
||||
|
||||
private static Channel BuildChannel() =>
|
||||
new(new Guid("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Number = "1",
|
||||
Name = "News",
|
||||
Group = "ErsatzTV",
|
||||
IsEnabled = true,
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
000 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Block Movie 01
|
||||
001 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Block Movie 02
|
||||
002 | 2026-01-16 09:00:00 - 2026-01-16 10:00:00 | None | Block Movie 03
|
||||
@@ -0,0 +1,72 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Movie 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:15:00 | None | Movie 02
|
||||
002 | 2026-01-15 01:15:00 - 2026-01-15 02:15:00 | None | Movie 03
|
||||
003 | 2026-01-15 02:15:00 - 2026-01-15 02:45:00 | None | Movie 04
|
||||
004 | 2026-01-15 02:45:00 - 2026-01-15 03:30:00 | None | Movie 05
|
||||
005 | 2026-01-15 03:30:00 - 2026-01-15 04:30:00 | None | Movie 06
|
||||
006 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Movie 01
|
||||
007 | 2026-01-15 05:00:00 - 2026-01-15 05:45:00 | None | Movie 02
|
||||
008 | 2026-01-15 05:45:00 - 2026-01-15 06:45:00 | None | Movie 03
|
||||
009 | 2026-01-15 06:45:00 - 2026-01-15 07:15:00 | None | Movie 04
|
||||
010 | 2026-01-15 07:15:00 - 2026-01-15 08:00:00 | None | Movie 05
|
||||
011 | 2026-01-15 08:00:00 - 2026-01-15 09:00:00 | None | Movie 06
|
||||
012 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Movie 01
|
||||
013 | 2026-01-15 09:30:00 - 2026-01-15 10:15:00 | None | Movie 02
|
||||
014 | 2026-01-15 10:15:00 - 2026-01-15 11:15:00 | None | Movie 03
|
||||
015 | 2026-01-15 11:15:00 - 2026-01-15 11:45:00 | None | Movie 04
|
||||
016 | 2026-01-15 11:45:00 - 2026-01-15 12:30:00 | None | Movie 05
|
||||
017 | 2026-01-15 12:30:00 - 2026-01-15 13:30:00 | None | Movie 06
|
||||
018 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Movie 01
|
||||
019 | 2026-01-15 14:00:00 - 2026-01-15 14:45:00 | None | Movie 02
|
||||
020 | 2026-01-15 14:45:00 - 2026-01-15 15:45:00 | None | Movie 03
|
||||
021 | 2026-01-15 15:45:00 - 2026-01-15 16:15:00 | None | Movie 04
|
||||
022 | 2026-01-15 16:15:00 - 2026-01-15 17:00:00 | None | Movie 05
|
||||
023 | 2026-01-15 17:00:00 - 2026-01-15 18:00:00 | None | Movie 06
|
||||
024 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Movie 01
|
||||
025 | 2026-01-15 18:30:00 - 2026-01-15 19:15:00 | None | Movie 02
|
||||
026 | 2026-01-15 19:15:00 - 2026-01-15 20:15:00 | None | Movie 03
|
||||
027 | 2026-01-15 20:15:00 - 2026-01-15 20:45:00 | None | Movie 04
|
||||
028 | 2026-01-15 20:45:00 - 2026-01-15 21:30:00 | None | Movie 05
|
||||
029 | 2026-01-15 21:30:00 - 2026-01-15 22:30:00 | None | Movie 06
|
||||
030 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Movie 01
|
||||
031 | 2026-01-15 23:00:00 - 2026-01-15 23:45:00 | None | Movie 02
|
||||
032 | 2026-01-15 23:45:00 - 2026-01-16 00:45:00 | None | Movie 03
|
||||
033 | 2026-01-16 00:45:00 - 2026-01-16 01:15:00 | None | Movie 04
|
||||
034 | 2026-01-16 01:15:00 - 2026-01-16 02:00:00 | None | Movie 05
|
||||
035 | 2026-01-16 02:00:00 - 2026-01-16 03:00:00 | None | Movie 06
|
||||
036 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Movie 01
|
||||
037 | 2026-01-16 03:30:00 - 2026-01-16 04:15:00 | None | Movie 02
|
||||
038 | 2026-01-16 04:15:00 - 2026-01-16 05:15:00 | None | Movie 03
|
||||
039 | 2026-01-16 05:15:00 - 2026-01-16 05:45:00 | None | Movie 04
|
||||
040 | 2026-01-16 05:45:00 - 2026-01-16 06:30:00 | None | Movie 05
|
||||
041 | 2026-01-16 06:30:00 - 2026-01-16 07:30:00 | None | Movie 06
|
||||
042 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Movie 01
|
||||
043 | 2026-01-16 08:00:00 - 2026-01-16 08:45:00 | None | Movie 02
|
||||
044 | 2026-01-16 08:45:00 - 2026-01-16 09:45:00 | None | Movie 03
|
||||
045 | 2026-01-16 09:45:00 - 2026-01-16 10:15:00 | None | Movie 04
|
||||
046 | 2026-01-16 10:15:00 - 2026-01-16 11:00:00 | None | Movie 05
|
||||
047 | 2026-01-16 11:00:00 - 2026-01-16 12:00:00 | None | Movie 06
|
||||
048 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Movie 01
|
||||
049 | 2026-01-16 12:30:00 - 2026-01-16 13:15:00 | None | Movie 02
|
||||
050 | 2026-01-16 13:15:00 - 2026-01-16 14:15:00 | None | Movie 03
|
||||
051 | 2026-01-16 14:15:00 - 2026-01-16 14:45:00 | None | Movie 04
|
||||
052 | 2026-01-16 14:45:00 - 2026-01-16 15:30:00 | None | Movie 05
|
||||
053 | 2026-01-16 15:30:00 - 2026-01-16 16:30:00 | None | Movie 06
|
||||
054 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Movie 01
|
||||
055 | 2026-01-16 17:00:00 - 2026-01-16 17:45:00 | None | Movie 02
|
||||
056 | 2026-01-16 17:45:00 - 2026-01-16 18:45:00 | None | Movie 03
|
||||
057 | 2026-01-16 18:45:00 - 2026-01-16 19:15:00 | None | Movie 04
|
||||
058 | 2026-01-16 19:15:00 - 2026-01-16 20:00:00 | None | Movie 05
|
||||
059 | 2026-01-16 20:00:00 - 2026-01-16 21:00:00 | None | Movie 06
|
||||
060 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Movie 01
|
||||
061 | 2026-01-16 21:30:00 - 2026-01-16 22:15:00 | None | Movie 02
|
||||
062 | 2026-01-16 22:15:00 - 2026-01-16 23:15:00 | None | Movie 03
|
||||
063 | 2026-01-16 23:15:00 - 2026-01-16 23:45:00 | None | Movie 04
|
||||
064 | 2026-01-16 23:45:00 - 2026-01-17 00:30:00 | None | Movie 05
|
||||
065 | 2026-01-17 00:30:00 - 2026-01-17 01:30:00 | None | Movie 06
|
||||
066 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Movie 01
|
||||
067 | 2026-01-17 02:00:00 - 2026-01-17 02:45:00 | None | Movie 02
|
||||
068 | 2026-01-17 02:45:00 - 2026-01-17 03:45:00 | None | Movie 03
|
||||
069 | 2026-01-17 03:45:00 - 2026-01-17 04:15:00 | None | Movie 04
|
||||
070 | 2026-01-17 04:15:00 - 2026-01-17 05:00:00 | None | Movie 05
|
||||
071 | 2026-01-17 05:00:00 - 2026-01-17 06:00:00 | None | Movie 06
|
||||
@@ -0,0 +1,602 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
// Golden-file characterization tests that lock the output of the playout builders — the core
|
||||
// scheduling surface that turns a ProgramSchedule/Block calendar + Collection into a concrete list of
|
||||
// PlayoutItems (issue #163). This slice covers the Classic builder (PlaybackOrder.Chronological) and
|
||||
// the Block builder. Sequential (YAML) + Scripted goldens are tracked as a follow-up in #381 (they need
|
||||
// a YAML fixture / an external-process harness respectively — not just a clock seam).
|
||||
//
|
||||
// This is the scheduling counterpart to ChannelPlaylistGoldenTests (#11, M3U) and
|
||||
// ChannelGuideGoldenTests (#28, XMLTV). Goldens live under Goldens/Goldens/ and are regenerated via
|
||||
// the Regenerate_goldens test or ETV_UPDATE_PLAYOUT_GOLDENS=1 — review the diff before committing.
|
||||
//
|
||||
// DETERMINISM: time enters the build ONLY via the pinned start (finish = start + 2 days); no wall clock
|
||||
// is read. We snapshot the raw PlayoutItem.Start/Finish (DateTime, treated as UTC) — NOT the *Offset
|
||||
// properties, which call .ToLocalTime() and would make the golden machine-timezone dependent.
|
||||
//
|
||||
// A few determinism invariants worth stating so a future reader doesn't "helpfully" break them:
|
||||
// * We snapshot the builder's raw output (buildResult.AddedItems), NOT the persisted playout. With
|
||||
// TrimStart, production would delete items before RemoveBefore (~start - 4h), so the golden's early
|
||||
// lines are pre-trim. That is intentional: this locks the BUILDER's output, and it is deterministic.
|
||||
// * Classic is TZ-independent for the captured fields (its internal DateTime->offset conversions only
|
||||
// gate the day-by-day loop; the anchor carries currentTime forward as UTC), so it needs no TZ guard —
|
||||
// unlike Block below. Do not add/remove a guard without re-checking this.
|
||||
// * ResetPlayout randomizes playout.Seed, but the classic fixture neutralizes it: RandomStartPoint and
|
||||
// ShuffleScheduleItems both default false and Chronological orders by (distinct) release date, so the
|
||||
// seed cannot perturb output. Introducing release-date ties or flipping those flags would reintroduce
|
||||
// nondeterminism.
|
||||
[TestFixture]
|
||||
public class PlayoutBuildGoldenTests
|
||||
{
|
||||
// Pinned build window — deterministic, UTC, no wall-clock dependency.
|
||||
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private SqliteConnection _connection;
|
||||
private IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task SetUpDatabase()
|
||||
{
|
||||
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
|
||||
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
|
||||
await _connection.OpenAsync();
|
||||
|
||||
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dbContextFactory = new TestTvContextFactory(options);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
|
||||
// replaying every migration. The MediaCollectionRepository's Dapper queries run against it fine.
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void TearDownDatabase() => _connection?.Dispose();
|
||||
|
||||
[Test]
|
||||
public Task Classic_chronological() => Verify("classic-chronological.txt", BuildChronologicalPlayout);
|
||||
|
||||
[Test]
|
||||
public Task Block_playout() => Verify("block.txt", BuildBlockPlayout);
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", "1");
|
||||
try
|
||||
{
|
||||
foreach (Func<Task> regen in new Func<Task>[] { Classic_chronological, Block_playout })
|
||||
{
|
||||
try
|
||||
{
|
||||
await regen();
|
||||
}
|
||||
catch (InconclusiveException)
|
||||
{
|
||||
// expected — Verify writes its golden then reports inconclusive
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS", null);
|
||||
}
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(
|
||||
string goldenName,
|
||||
Func<Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)>> build)
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await build();
|
||||
|
||||
string actual = Canonicalize(Snapshot(items, titles));
|
||||
|
||||
string path = Path.Combine(GoldenDir(), goldenName);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ETV_UPDATE_PLAYOUT_GOLDENS") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir());
|
||||
await File.WriteAllTextAsync(path, actual);
|
||||
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_PLAYOUT_GOLDENS=1) and commit it.");
|
||||
|
||||
string expected = Canonicalize(await File.ReadAllTextAsync(path));
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildChronologicalPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
// Seed a fresh, deterministic dataset for this build. Titles + release dates are fixed and the
|
||||
// durations vary (30/45/60) so the chronological ordering and item boundaries are visible.
|
||||
var (playoutId, titles) = await SeedData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||||
|
||||
// Build ONCE with Reset over the pinned 2-day window (internal overload = explicit start/finish).
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Test LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Six movies, fixed titles + release dates, varied durations to make ordering/boundaries visible.
|
||||
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 6; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2000, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(collection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Test FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Name = "Test Channel",
|
||||
Number = "1",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private static async Task<PlayoutReferenceData> GetReferenceData(TvContext dbContext, int playoutId)
|
||||
{
|
||||
Channel channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
|
||||
.AsNoTracking()
|
||||
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
programSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// --- Block builder ---
|
||||
//
|
||||
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
|
||||
// EffectiveBlock.GetEffectiveBlocks(..., TimeZoneInfo.Local, ...), so its output is machine-timezone
|
||||
// dependent. This is a CHARACTERIZATION test: rather than change production code to inject the zone
|
||||
// (that seam is issue #380's scope), we capture the golden under UTC and GUARD with Assume.That so the
|
||||
// test RUNS under TZ=UTC (CI) and reports INCONCLUSIVE (a graceful skip, not a failure) under any other
|
||||
// TZ — mirroring ChannelPlaylistGoldenTests' GuardVolatileEnvironment. Classic + other TZ-independent
|
||||
// goldens are unaffected.
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildBlockPlayout()
|
||||
{
|
||||
// Guard on the offset AT the build instant (GetUtcOffset(Start)), not BaseUtcOffset: the latter is
|
||||
// zero for DST zones like Europe/London year-round, so it would pass in a summer-dated fixture where
|
||||
// London != UTC. GetUtcOffset pins the actual instant and stays correct regardless of fixture date.
|
||||
Assume.That(
|
||||
TimeZoneInfo.Local.GetUtcOffset(Start),
|
||||
Is.EqualTo(TimeSpan.Zero),
|
||||
"Block golden is captured under UTC; run with TZ=UTC. A real TZ seam is issue #380's scope.");
|
||||
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedBlockData(cancellationToken);
|
||||
|
||||
var builder = new BlockPlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<ICollectionEtag>(),
|
||||
NullLogger<BlockPlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetBlockReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
Start,
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildMode.Reset,
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedBlockData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Block LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Six movies, fixed titles + release dates, varied durations so chronological ordering and block
|
||||
// boundaries are visible across the scheduled blocks.
|
||||
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 6; i++)
|
||||
{
|
||||
var movie = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
|
||||
},
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"Block Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
movies.Add(movie);
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var collection = new Collection
|
||||
{
|
||||
Name = "Block Test Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(collection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// A single 60-minute block with three chronological items over the same collection. With
|
||||
// AfterDurationEnd, each block fills until currentTime passes the block finish; history carries the
|
||||
// chronological cursor across the blocks scheduled on successive days.
|
||||
var blockGroup = new BlockGroup { Name = "Block Test Group" };
|
||||
await context.BlockGroups.AddAsync(blockGroup, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var block = new Block
|
||||
{
|
||||
BlockGroup = blockGroup,
|
||||
BlockGroupId = blockGroup.Id,
|
||||
Name = "Test Block",
|
||||
Minutes = 60,
|
||||
StopScheduling = BlockStopScheduling.AfterDurationEnd,
|
||||
Items = new List<BlockItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Index = 1,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new()
|
||||
{
|
||||
Index = 2,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new()
|
||||
{
|
||||
Index = 3,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
}
|
||||
};
|
||||
await context.Blocks.AddAsync(block, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var templateGroup = new TemplateGroup { Name = "Template Test Group" };
|
||||
await context.TemplateGroups.AddAsync(templateGroup, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var template = new Template
|
||||
{
|
||||
TemplateGroup = templateGroup,
|
||||
TemplateGroupId = templateGroup.Id,
|
||||
Name = "Test Template",
|
||||
Items = new List<TemplateItem>()
|
||||
};
|
||||
template.Items.Add(new TemplateItem
|
||||
{
|
||||
Block = block,
|
||||
BlockId = block.Id,
|
||||
StartTime = TimeSpan.FromHours(9)
|
||||
});
|
||||
await context.Templates.AddAsync(template, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Block FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000002"))
|
||||
{
|
||||
Name = "Block Test Channel",
|
||||
Number = "2",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Block
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playoutTemplate = new PlayoutTemplate
|
||||
{
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Template = template,
|
||||
TemplateId = template.Id,
|
||||
Index = 1,
|
||||
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
|
||||
};
|
||||
await context.PlayoutTemplates.AddAsync(playoutTemplate, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private static async Task<PlayoutReferenceData> GetBlockReferenceData(TvContext dbContext, int playoutId)
|
||||
{
|
||||
Channel channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
List<PlayoutItem> existingItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.PlayoutId == playoutId)
|
||||
.ToListAsync();
|
||||
|
||||
List<PlayoutTemplate> playoutTemplates = await dbContext.PlayoutTemplates
|
||||
.AsNoTracking()
|
||||
.Where(pt => pt.PlayoutId == playoutId)
|
||||
.Include(t => t.Template)
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Block)
|
||||
.ThenInclude(b => b.Items)
|
||||
.Include(t => t.DecoTemplate)
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Deco)
|
||||
.ToListAsync();
|
||||
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
existingItems,
|
||||
playoutTemplates,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
|
||||
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
|
||||
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
|
||||
{
|
||||
var ordered = items
|
||||
.OrderBy(i => i.Start)
|
||||
.ThenBy(i => i.MediaItemId)
|
||||
.ToList();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var index = 0; index < ordered.Count; index++)
|
||||
{
|
||||
PlayoutItem item = ordered[index];
|
||||
string title = titles.TryGetValue(item.MediaItemId, out string t) ? t : $"#{item.MediaItemId}";
|
||||
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
|
||||
sb.Append(" | ");
|
||||
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||||
sb.Append(" - ");
|
||||
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
|
||||
sb.Append(" | ");
|
||||
sb.Append(item.FillerKind.ToString());
|
||||
sb.Append(" | ");
|
||||
sb.Append(title);
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Canonicalize(string text) =>
|
||||
text.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
|
||||
|
||||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||||
|
||||
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
||||
{
|
||||
public TvContext CreateDbContext() =>
|
||||
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
public record AutoTuneProposalResponseModel(
|
||||
string Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number,
|
||||
int ItemCount,
|
||||
bool AlreadyExists);
|
||||
|
||||
public record AutoTuneChannelResultModel(
|
||||
string Name,
|
||||
string Status,
|
||||
int? ChannelId,
|
||||
string? Reason);
|
||||
|
||||
public record AutoTuneResultResponseModel(
|
||||
List<AutoTuneChannelResultModel> Results,
|
||||
int CreatedCount,
|
||||
int SkippedCount,
|
||||
int FailedCount);
|
||||
@@ -14,7 +14,6 @@ public record ChannelGuideProgrammeResponseModel(
|
||||
|
||||
/// <summary>One channel's guide programmes for the requested window.</summary>
|
||||
public record ChannelGuideChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
List<ChannelGuideProgrammeResponseModel> Programmes);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
/// <summary>The kind of source selected by the channel schedule.</summary>
|
||||
public enum ChannelPlaybackSourceKind
|
||||
{
|
||||
LocalFile,
|
||||
JellyfinItem,
|
||||
PlexItem,
|
||||
EmbyItem,
|
||||
RemoteUrl,
|
||||
Unsupported
|
||||
}
|
||||
|
||||
/// <summary>A token-free reference to the scheduled media source.</summary>
|
||||
public record ChannelPlaybackSourceReferenceResponseModel(
|
||||
ChannelPlaybackSourceKind Kind,
|
||||
string? ItemId,
|
||||
string? Path,
|
||||
bool IsLive);
|
||||
|
||||
/// <summary>The physical playout item covering the requested channel time.</summary>
|
||||
public record ChannelPlaybackItemResponseModel(
|
||||
int PlayoutItemId,
|
||||
int MediaItemId,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
long InPointTicks,
|
||||
long CurrentOffsetTicks,
|
||||
long OutPointTicks,
|
||||
FillerKind FillerKind,
|
||||
ChannelPlaybackSourceReferenceResponseModel Source);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a viewer-facing channel time to the physical media item selected by the schedule.
|
||||
/// This is intentionally playback-engine neutral and never starts an ErsatzTV transcoder.
|
||||
/// </summary>
|
||||
public record ChannelPlaybackSourceResponseModel(
|
||||
int ChannelId,
|
||||
int SourceChannelId,
|
||||
DateTimeOffset ResolvedAt,
|
||||
DateTimeOffset SourceAt,
|
||||
DateTimeOffset? NextTransitionAt,
|
||||
ChannelPlaybackItemResponseModel? Active);
|
||||
@@ -12,4 +12,5 @@ public record PlayoutListItemResponseModel(
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
bool IsLocked);
|
||||
bool IsLocked,
|
||||
int Seed);
|
||||
|
||||
@@ -15,7 +15,8 @@ public record PlayoutResponseModel(
|
||||
PlayoutBuildStatusResponseModel? BuildStatus,
|
||||
int? DecoId,
|
||||
string? DecoName,
|
||||
bool IsLocked)
|
||||
bool IsLocked,
|
||||
int Seed)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
@@ -29,7 +30,8 @@ public record PlayoutResponseModel(
|
||||
PlayoutBuildStatusResponseModel? buildStatus,
|
||||
int? decoId,
|
||||
string? decoName,
|
||||
bool isLocked) =>
|
||||
bool isLocked,
|
||||
int seed) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
@@ -42,5 +44,6 @@ public record PlayoutResponseModel(
|
||||
buildStatus,
|
||||
decoId,
|
||||
decoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
seed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Settings;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
|
||||
/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path".
|
||||
/// </summary>
|
||||
public record IptvSettingsResponseModel(string BaseUrl);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum AutoTuneAxis
|
||||
{
|
||||
TvShow = 0,
|
||||
TvGenre = 1,
|
||||
MovieGenre = 2
|
||||
}
|
||||
@@ -62,6 +62,11 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
|
||||
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
|
||||
|
||||
// Optional advertised IPTV base URL (issue #340). When set, overrides the request-derived
|
||||
// scheme/host/PathBase used to build absolute M3U + XMLTV URLs; when blank/unset, request-derived
|
||||
// values are used (today's behavior). Distinct from ETV_BASE_URL, which only sets ASP.NET PathBase.
|
||||
public static ConfigElementKey IptvBaseUrl => new("iptv.base_url");
|
||||
|
||||
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
|
||||
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
|
||||
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ErsatzTV.Core.Iptv;
|
||||
|
||||
// Central resolution/validation for the optional advertised IPTV base URL (issue #340).
|
||||
//
|
||||
// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase.
|
||||
// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g.
|
||||
// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted
|
||||
// URLs break. An operator can configure an advertised base URL to override those request-derived
|
||||
// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces.
|
||||
//
|
||||
// When the configured value is blank or invalid, resolution falls back to the request-derived values
|
||||
// so today's behavior is preserved byte-for-byte.
|
||||
public static class AdvertisedBaseUrl
|
||||
{
|
||||
// Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured
|
||||
// value is blank or invalid, returns the request-derived values unchanged.
|
||||
public static (string Scheme, string Host, string BaseUrl) Resolve(
|
||||
string configured,
|
||||
string requestScheme,
|
||||
string requestHost,
|
||||
string requestBaseUrl) =>
|
||||
TryParse(configured).Match(
|
||||
Some: parsed => parsed,
|
||||
None: () => (requestScheme, requestHost, requestBaseUrl));
|
||||
|
||||
// Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an
|
||||
// absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix
|
||||
// are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and
|
||||
// "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate).
|
||||
public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri.Host))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets)
|
||||
// and excludes any userinfo — exactly the "{host}" the URL builders expect.
|
||||
string baseUrl = uri.AbsolutePath.TrimEnd('/');
|
||||
return (uri.Scheme, uri.Authority, baseUrl);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<UserSecretsId>729e6271-c307-43c8-8e36-1b36c39f6de2</UserSecretsId>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class AutoTuneAxisMapTests
|
||||
{
|
||||
[Test]
|
||||
public void GenerateQuery_Builds_Expected_Lucene()
|
||||
{
|
||||
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "The Office")
|
||||
.ShouldBe("type:episode AND show_title:\"The Office\"");
|
||||
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvGenre, "Comedy")
|
||||
.ShouldBe("type:episode AND genre:\"Comedy\"");
|
||||
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.MovieGenre, "Action")
|
||||
.ShouldBe("type:movie AND genre:\"Action\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateQuery_Escapes_Quotes_And_Backslashes()
|
||||
{
|
||||
AutoTuneAxisMap.GenerateQuery(AutoTuneAxis.TvShow, "Bob\"s \\Show")
|
||||
.ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GenerateName_Suffixes_Movie_Genres_Only()
|
||||
{
|
||||
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvShow, "The Office").ShouldBe("The Office");
|
||||
AutoTuneAxisMap.GenerateName(AutoTuneAxis.TvGenre, "Comedy").ShouldBe("Comedy");
|
||||
AutoTuneAxisMap.GenerateName(AutoTuneAxis.MovieGenre, "Action").ShouldBe("Action Movies");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlaybackOrderFor_Uses_PseudoTV_Defaults()
|
||||
{
|
||||
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvShow).ShouldBe(PlaybackOrder.SeasonEpisode);
|
||||
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle);
|
||||
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class AutoTuneNumberAllocatorTests
|
||||
{
|
||||
[Test]
|
||||
public void Allocate_Skips_Taken_Numbers()
|
||||
{
|
||||
var existing = new HashSet<string> { "500", "502" };
|
||||
List<string> result = AutoTuneNumberAllocator.Allocate(500, 3, existing);
|
||||
result.ShouldBe(new List<string> { "501", "503", "504" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Allocate_From_Empty_Is_Sequential()
|
||||
{
|
||||
List<string> result = AutoTuneNumberAllocator.Allocate(1, 3, new HashSet<string>());
|
||||
result.ShouldBe(new List<string> { "1", "2", "3" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Allocate_Zero_Count_Is_Empty()
|
||||
{
|
||||
AutoTuneNumberAllocator.Allocate(500, 0, new HashSet<string>()).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class CreateAutoTunedChannelsHandlerTests
|
||||
{
|
||||
private ISender _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<ISender>();
|
||||
// Smart collection creation always succeeds, echoing an incrementing id.
|
||||
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var cmd = ci.Arg<CreateSmartCollection>();
|
||||
return (Either<BaseError, SmartCollectionViewModel>)
|
||||
new SmartCollectionViewModel(7, cmd.Name, cmd.Query);
|
||||
});
|
||||
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Either<BaseError, LanguageExt.Unit>)LanguageExt.Unit.Default);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creates_Selected_Channels_And_Reports_Counts()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
||||
new CreateChannelFromLineupResponseModel(88, null, 1, 2));
|
||||
|
||||
var result = await Handle(new CreateAutoTunedChannels(
|
||||
TemplateId: 3, Group: "Auto-Tuned",
|
||||
new List<AutoTuneChannelSelection>
|
||||
{
|
||||
new(AutoTuneAxis.TvShow, "The Office", "The Office", "500")
|
||||
}));
|
||||
|
||||
result.CreatedCount.ShouldBe(1);
|
||||
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Created);
|
||||
result.Results[0].ChannelId.ShouldBe(88);
|
||||
|
||||
// Smart collection built with the server-generated query.
|
||||
await _mediator.Received().Send(
|
||||
Arg.Is<CreateSmartCollection>(c => c.Query == "type:episode AND show_title:\"The Office\""),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
// Channel created referencing the smart collection id, number, and SeasonEpisode order.
|
||||
await _mediator.Received().Send(
|
||||
Arg.Is<CreateChannelFromLineup>(c =>
|
||||
c.Number == "500" &&
|
||||
c.TemplateId == 3 &&
|
||||
c.Advanced.PlaybackOrder == PlaybackOrder.SeasonEpisode &&
|
||||
c.Lineup.Count == 1 &&
|
||||
c.Lineup[0].CollectionType == CollectionType.SmartCollection &&
|
||||
c.Lineup[0].SmartCollectionId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Number_Collision_Is_Skipped_Not_Failed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
||||
BaseError.New("Channel number must be unique"));
|
||||
|
||||
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
|
||||
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
|
||||
|
||||
result.SkippedCount.ShouldBe(1);
|
||||
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Number_Collision_Rolls_Back_Orphaned_SmartCollection()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
||||
BaseError.New("Channel number must be unique"));
|
||||
|
||||
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
|
||||
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
|
||||
|
||||
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Skipped);
|
||||
|
||||
await _mediator.Received().Send(
|
||||
Arg.Is<DeleteSmartCollection>(d => d.SmartCollectionId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Other_Errors_Are_Failed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
||||
.Returns((Either<BaseError, CreateChannelFromLineupResponseModel>)
|
||||
BaseError.New("FFmpegProfile 9 does not exist."));
|
||||
|
||||
var result = await Handle(new CreateAutoTunedChannels(3, "Auto-Tuned",
|
||||
new List<AutoTuneChannelSelection> { new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500") }));
|
||||
|
||||
result.FailedCount.ShouldBe(1);
|
||||
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
|
||||
}
|
||||
|
||||
private Task<AutoTuneResult> Handle(CreateAutoTunedChannels request) =>
|
||||
new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None);
|
||||
}
|
||||
@@ -61,7 +61,6 @@ public class GetChannelGuideDataHandlerTests
|
||||
CancellationToken.None);
|
||||
|
||||
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
|
||||
result.Channels.Single().Id.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelPlaybackSourceHandlerTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Resolve_Jellyfin_Item_And_Schedule_Offset()
|
||||
{
|
||||
string jellyfinItemId = Guid.NewGuid().ToString("N");
|
||||
DateTime start = Now.UtcDateTime.AddMinutes(-10);
|
||||
DateTime finish = Now.UtcDateTime.AddMinutes(20);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel channel = MakeChannel(7, "7.1");
|
||||
var movie = new JellyfinMovie
|
||||
{
|
||||
Id = 70,
|
||||
ItemId = jellyfinItemId,
|
||||
Etag = string.Empty,
|
||||
MovieMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
var playout = new Playout { Id = 71, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 72,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
InPoint = TimeSpan.FromMinutes(5),
|
||||
OutPoint = TimeSpan.FromMinutes(35),
|
||||
FillerKind = FillerKind.None
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.JellyfinMovies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(7, Now);
|
||||
|
||||
result.ChannelId.ShouldBe(7);
|
||||
result.SourceChannelId.ShouldBe(7);
|
||||
result.SourceAt.ShouldBe(Now);
|
||||
result.NextTransitionAt.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
result.Active.ShouldNotBeNull();
|
||||
result.Active.Start.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
|
||||
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(15).Ticks);
|
||||
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
|
||||
result.Active.Source.ItemId.ShouldBe(jellyfinItemId);
|
||||
result.Active.Source.Path.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Use_Actual_Filler_Item_Not_Guide_Display_Item()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel channel = MakeChannel(8, "8");
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 80,
|
||||
OtherVideoMetadata = [],
|
||||
MediaVersions =
|
||||
[
|
||||
new MediaVersion
|
||||
{
|
||||
MediaFiles = [new MediaFile { Path = "/media/bumper.mkv", PathHash = "bumper" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
var playout = new Playout { Id = 81, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 82,
|
||||
MediaItem = filler,
|
||||
MediaItemId = filler.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(-1),
|
||||
Finish = Now.UtcDateTime.AddMinutes(1),
|
||||
OutPoint = TimeSpan.FromMinutes(2),
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
GuideGroup = 4
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(8, Now);
|
||||
|
||||
result.Active.ShouldNotBeNull();
|
||||
result.Active.PlayoutItemId.ShouldBe(82);
|
||||
result.Active.FillerKind.ShouldBe(FillerKind.MidRoll);
|
||||
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.LocalFile);
|
||||
result.Active.Source.Path.ShouldBe("/media/bumper.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Apply_Mirror_Clock_And_Viewer_Facing_Timestamps()
|
||||
{
|
||||
TimeSpan offset = TimeSpan.FromHours(1);
|
||||
DateTime sourceStart = Now.UtcDateTime.Subtract(offset).AddMinutes(-5);
|
||||
DateTime sourceFinish = Now.UtcDateTime.Subtract(offset).AddMinutes(25);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel source = MakeChannel(9, "9");
|
||||
Channel mirror = MakeChannel(10, "10");
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannelId = source.Id;
|
||||
mirror.PlayoutOffset = offset;
|
||||
|
||||
var remote = new RemoteStream
|
||||
{
|
||||
Id = 90,
|
||||
Url = "https://example.invalid/live.m3u8",
|
||||
IsLive = true,
|
||||
RemoteStreamMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
var playout = new Playout { Id = 91, Channel = source, ChannelId = source.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 92,
|
||||
MediaItem = remote,
|
||||
MediaItemId = remote.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = sourceStart,
|
||||
Finish = sourceFinish,
|
||||
OutPoint = TimeSpan.FromMinutes(30)
|
||||
};
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
context.RemoteStreams.Add(remote);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(10, Now);
|
||||
|
||||
result.SourceChannelId.ShouldBe(9);
|
||||
result.SourceAt.ShouldBe(Now.Subtract(offset));
|
||||
result.Active.ShouldNotBeNull();
|
||||
result.Active.Start.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
|
||||
result.Active.Finish.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
|
||||
result.NextTransitionAt.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
|
||||
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.FromMinutes(5).Ticks);
|
||||
result.Active.Source.Kind.ShouldBe(ChannelPlaybackSourceKind.RemoteUrl);
|
||||
result.Active.Source.IsLive.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Select_Item_At_Start_And_Exclude_Item_At_Finish()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel channel = MakeChannel(11, "11");
|
||||
var first = new Movie { Id = 110, MovieMetadata = [], MediaVersions = [] };
|
||||
var second = new Movie { Id = 111, MovieMetadata = [], MediaVersions = [] };
|
||||
var playout = new Playout { Id = 112, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.AddRange(first, second);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.AddRange(
|
||||
new PlayoutItem
|
||||
{
|
||||
Id = 113,
|
||||
MediaItem = first,
|
||||
MediaItemId = first.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(-30),
|
||||
Finish = Now.UtcDateTime
|
||||
},
|
||||
new PlayoutItem
|
||||
{
|
||||
Id = 114,
|
||||
MediaItem = second,
|
||||
MediaItemId = second.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime,
|
||||
Finish = Now.UtcDateTime.AddMinutes(30)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(11, Now);
|
||||
|
||||
result.Active.ShouldNotBeNull();
|
||||
result.Active.PlayoutItemId.ShouldBe(114);
|
||||
result.Active.MediaItemId.ShouldBe(111);
|
||||
result.Active.Start.ShouldBe(Now);
|
||||
result.Active.CurrentOffsetTicks.ShouldBe(TimeSpan.Zero.Ticks);
|
||||
result.NextTransitionAt.ShouldBe(Now.AddMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Next_Start_During_Gap()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel channel = MakeChannel(12, "12");
|
||||
var movie = new Movie { Id = 120, MovieMetadata = [], MediaVersions = [] };
|
||||
var playout = new Playout { Id = 121, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.AddRange(
|
||||
new PlayoutItem
|
||||
{
|
||||
Id = 122,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(-30),
|
||||
Finish = Now.UtcDateTime
|
||||
},
|
||||
new PlayoutItem
|
||||
{
|
||||
Id = 123,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(10),
|
||||
Finish = Now.UtcDateTime.AddMinutes(40)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(12, Now);
|
||||
|
||||
result.Active.ShouldBeNull();
|
||||
result.NextTransitionAt.ShouldBe(Now.AddMinutes(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_No_Next_Transition_During_Terminal_Gap()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel channel = MakeChannel(13, "13");
|
||||
var movie = new Movie { Id = 130, MovieMetadata = [], MediaVersions = [] };
|
||||
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var finishedItem = new PlayoutItem
|
||||
{
|
||||
Id = 132,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddHours(-1),
|
||||
Finish = Now.UtcDateTime.AddMinutes(-30)
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(finishedItem);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceResponseModel result = await GetResult(13, Now);
|
||||
|
||||
result.Active.ShouldBeNull();
|
||||
result.NextTransitionAt.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Map_Server_Items_And_Unsupported_Empty_Local_Media()
|
||||
{
|
||||
const string jellyfinItemId = "jellyfin-episode";
|
||||
const string plexKey = "/library/metadata/123";
|
||||
const string embyItemId = "emby-movie";
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
var jellyfinEpisode = new JellyfinEpisode
|
||||
{
|
||||
Id = 140,
|
||||
ItemId = jellyfinItemId,
|
||||
Etag = string.Empty,
|
||||
EpisodeMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
var plexMovie = new PlexMovie
|
||||
{
|
||||
Id = 150,
|
||||
Key = plexKey,
|
||||
Etag = string.Empty,
|
||||
MovieMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
var embyMovie = new EmbyMovie
|
||||
{
|
||||
Id = 160,
|
||||
ItemId = embyItemId,
|
||||
Etag = string.Empty,
|
||||
MovieMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
var emptyLocalMovie = new Movie { Id = 170, MovieMetadata = [], MediaVersions = [] };
|
||||
|
||||
AddActiveItem(context, MakeChannel(14, "14"), jellyfinEpisode, 141, 142);
|
||||
AddActiveItem(context, MakeChannel(15, "15"), plexMovie, 151, 152);
|
||||
AddActiveItem(context, MakeChannel(16, "16"), embyMovie, 161, 162);
|
||||
AddActiveItem(context, MakeChannel(17, "17"), emptyLocalMovie, 171, 172);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
ChannelPlaybackSourceReferenceResponseModel jellyfin = (await GetResult(14, Now)).Active!.Source;
|
||||
ChannelPlaybackSourceReferenceResponseModel plex = (await GetResult(15, Now)).Active!.Source;
|
||||
ChannelPlaybackSourceReferenceResponseModel emby = (await GetResult(16, Now)).Active!.Source;
|
||||
ChannelPlaybackSourceReferenceResponseModel unsupported = (await GetResult(17, Now)).Active!.Source;
|
||||
|
||||
jellyfin.Kind.ShouldBe(ChannelPlaybackSourceKind.JellyfinItem);
|
||||
jellyfin.ItemId.ShouldBe(jellyfinItemId);
|
||||
plex.Kind.ShouldBe(ChannelPlaybackSourceKind.PlexItem);
|
||||
plex.ItemId.ShouldBe(plexKey);
|
||||
emby.Kind.ShouldBe(ChannelPlaybackSourceKind.EmbyItem);
|
||||
emby.ItemId.ShouldBe(embyItemId);
|
||||
unsupported.Kind.ShouldBe(ChannelPlaybackSourceKind.Unsupported);
|
||||
unsupported.ItemId.ShouldBeNull();
|
||||
unsupported.Path.ShouldBeNull();
|
||||
unsupported.IsLive.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_None_For_Missing_Channel()
|
||||
{
|
||||
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
|
||||
|
||||
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
|
||||
new GetChannelPlaybackSource(404, Now),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_None_For_Mirror_Without_Source_Channel()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
Channel mirror = MakeChannel(18, "18");
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
var staleMovie = new Movie { Id = 180, MovieMetadata = [], MediaVersions = [] };
|
||||
var stalePlayout = new Playout
|
||||
{
|
||||
Id = 181,
|
||||
Channel = mirror,
|
||||
ChannelId = mirror.Id,
|
||||
Items = []
|
||||
};
|
||||
var staleItem = new PlayoutItem
|
||||
{
|
||||
Id = 182,
|
||||
MediaItem = staleMovie,
|
||||
MediaItemId = staleMovie.Id,
|
||||
Playout = stalePlayout,
|
||||
PlayoutId = stalePlayout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(-1),
|
||||
Finish = Now.UtcDateTime.AddMinutes(1)
|
||||
};
|
||||
context.Channels.Add(mirror);
|
||||
context.Movies.Add(staleMovie);
|
||||
context.Playouts.Add(stalePlayout);
|
||||
context.PlayoutItems.Add(staleItem);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
|
||||
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
|
||||
new GetChannelPlaybackSource(18, Now),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private async Task<ChannelPlaybackSourceResponseModel> GetResult(int channelId, DateTimeOffset at)
|
||||
{
|
||||
var handler = new GetChannelPlaybackSourceHandler(_db.Factory);
|
||||
Option<ChannelPlaybackSourceResponseModel> result = await handler.Handle(
|
||||
new GetChannelPlaybackSource(channelId, at),
|
||||
CancellationToken.None);
|
||||
return result.Match(
|
||||
Some: value => value,
|
||||
None: () => throw new AssertionException("Expected a playback-source response"));
|
||||
}
|
||||
|
||||
private static void AddActiveItem(
|
||||
TvContext context,
|
||||
Channel channel,
|
||||
MediaItem mediaItem,
|
||||
int playoutId,
|
||||
int playoutItemId)
|
||||
{
|
||||
var playout = new Playout { Id = playoutId, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = playoutItemId,
|
||||
MediaItem = mediaItem,
|
||||
MediaItemId = mediaItem.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.UtcDateTime.AddMinutes(-1),
|
||||
Finish = Now.UtcDateTime.AddMinutes(1)
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.MediaItems.Add(mediaItem);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
}
|
||||
|
||||
private static Channel MakeChannel(int id, string number) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
SortNumber = id,
|
||||
Name = $"Channel {number}",
|
||||
Group = "Test",
|
||||
Categories = string.Empty,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
@@ -18,12 +19,14 @@ public class GetChannelStatesForApiHandlerTests
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
private IDirectStreamSessionTracker _directStreams = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
_directStreams = Substitute.For<IDirectStreamSessionTracker>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
@@ -36,7 +39,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
await SeedChannelWithMovie(start, finish);
|
||||
_segmenter.IsActive("7.1").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -51,11 +54,26 @@ public class GetChannelStatesForApiHandlerTests
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_OnAir_For_Direct_Stream_Session()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
_directStreams.IsActive("8").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().OnAir.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
|
||||
{
|
||||
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -67,7 +85,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
|
||||
{
|
||||
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -81,7 +99,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -133,7 +151,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -176,7 +194,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -216,7 +234,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
ChannelStateResponseModel state =
|
||||
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
|
||||
@@ -264,7 +282,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class PreviewAutoTuneChannelsHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Enumerates_Shows_Above_MinItems_With_Counts_And_Numbers()
|
||||
{
|
||||
// Show 1 "The Office" with 3 episodes; Show 2 "Short" with 1 episode.
|
||||
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102, 103 });
|
||||
await SeedShow(showId: 2, title: "Short", seasonId: 22, episodeIds: new[] { 201 });
|
||||
|
||||
var result = await Handle(new PreviewAutoTuneChannels(
|
||||
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 2, StartingNumber: 500));
|
||||
|
||||
List<AutoTuneProposal> proposals = RightOf(result);
|
||||
proposals.Count.ShouldBe(1);
|
||||
proposals[0].Value.ShouldBe("The Office");
|
||||
proposals[0].Name.ShouldBe("The Office");
|
||||
proposals[0].ItemCount.ShouldBe(3);
|
||||
proposals[0].Number.ShouldBe("500");
|
||||
proposals[0].AlreadyExists.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Flags_AlreadyExists_By_Channel_Name_And_Skips_Taken_Numbers()
|
||||
{
|
||||
await SeedShow(showId: 1, title: "The Office", seasonId: 11, episodeIds: new[] { 101, 102 });
|
||||
await SeedChannel(number: "500", name: "The Office");
|
||||
|
||||
var result = await Handle(new PreviewAutoTuneChannels(
|
||||
new List<AutoTuneAxis> { AutoTuneAxis.TvShow }, MinItems: 1, StartingNumber: 500));
|
||||
|
||||
List<AutoTuneProposal> proposals = RightOf(result);
|
||||
proposals[0].AlreadyExists.ShouldBeTrue();
|
||||
proposals[0].Number.ShouldBe("501"); // 500 is taken
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Enumerates_Movie_Genres_With_Suffixed_Names()
|
||||
{
|
||||
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: "Action");
|
||||
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: "Action");
|
||||
await SeedMovieWithGenre(movieId: 3, metadataId: 3, genre: "Drama");
|
||||
|
||||
var result = await Handle(new PreviewAutoTuneChannels(
|
||||
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
|
||||
|
||||
List<AutoTuneProposal> proposals = RightOf(result);
|
||||
proposals.Count.ShouldBe(1); // Drama has only 1 movie, below minItems
|
||||
proposals[0].Value.ShouldBe("Action");
|
||||
proposals[0].Name.ShouldBe("Action Movies");
|
||||
proposals[0].ItemCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Excludes_Proposals_Whose_Generated_Name_Exceeds_50_Chars()
|
||||
{
|
||||
// "Movies" suffix (7 chars) pushes this over the 50-char Channel.Name limit.
|
||||
const string longGenre = "A Really Really Long And Overly Descriptive Genre"; // 50 chars, +" Movies" = 57
|
||||
await SeedMovieWithGenre(movieId: 1, metadataId: 1, genre: longGenre);
|
||||
await SeedMovieWithGenre(movieId: 2, metadataId: 2, genre: longGenre);
|
||||
|
||||
var result = await Handle(new PreviewAutoTuneChannels(
|
||||
new List<AutoTuneAxis> { AutoTuneAxis.MovieGenre }, MinItems: 2, StartingNumber: 500));
|
||||
|
||||
List<AutoTuneProposal> proposals = RightOf(result);
|
||||
proposals.ShouldNotContain(p => p.Value == longGenre);
|
||||
proposals.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Axes_Is_Error()
|
||||
{
|
||||
var result = await Handle(new PreviewAutoTuneChannels(
|
||||
new List<AutoTuneAxis>(), MinItems: 5, StartingNumber: 500));
|
||||
LeftOf(result).Value.ShouldContain("axis");
|
||||
}
|
||||
|
||||
private Task<Either<BaseError, List<AutoTuneProposal>>> Handle(PreviewAutoTuneChannels request) =>
|
||||
new PreviewAutoTuneChannelsHandler(_db.Factory).Handle(request, CancellationToken.None);
|
||||
|
||||
private async Task SeedShow(int showId, string title, int seasonId, int[] episodeIds)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Shows.Add(new Show
|
||||
{
|
||||
Id = showId,
|
||||
ShowMetadata = new List<ShowMetadata> { new() { ShowId = showId, Title = title } },
|
||||
Seasons = new List<Season>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = seasonId, ShowId = showId,
|
||||
Episodes = episodeIds.Select(id => new Episode { Id = id, SeasonId = seasonId }).ToList()
|
||||
}
|
||||
}
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedMovieWithGenre(int movieId, int metadataId, string genre)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Movies.Add(new Movie
|
||||
{
|
||||
Id = movieId,
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Id = metadataId, MovieId = movieId, Title = $"Movie {movieId}",
|
||||
Genres = new List<Genre> { new() { Name = genre } } }
|
||||
}
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedChannel(string number, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(new Channel(System.Guid.NewGuid())
|
||||
{
|
||||
Number = number, Name = name, Group = "Test", SortNumber = double.Parse(number)
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> e) =>
|
||||
e.Match(Left: x => x, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
private static TR RightOf<TR>(Either<BaseError, TR> e) =>
|
||||
e.Match(Left: x => throw new AssertionException($"Expected Right, got {x.Value}"), Right: r => r);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Configuration;
|
||||
|
||||
[TestFixture]
|
||||
public class IptvSettingsHandlerTests
|
||||
{
|
||||
private const string Key = "iptv.base_url";
|
||||
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => _configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
[Test]
|
||||
public async Task Get_Returns_Empty_When_Unset()
|
||||
{
|
||||
_configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
|
||||
|
||||
result.BaseUrl.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Returns_Stored_Value()
|
||||
{
|
||||
_configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://tv.example.com/etv"));
|
||||
|
||||
var handler = new GetIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
|
||||
|
||||
result.BaseUrl.ShouldBe("https://tv.example.com/etv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Upserts_Trimmed_Value_When_Valid()
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _configElementRepository.Received(1).Upsert(
|
||||
Arg.Is<ConfigElementKey>(k => k.Key == Key),
|
||||
"https://public.example.com/etv",
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public async Task Update_Clears_Setting_When_Blank(string blank)
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _configElementRepository.Received(1).Delete(
|
||||
Arg.Is<ConfigElementKey>(k => k.Key == Key),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Upsert(
|
||||
Arg.Any<ConfigElementKey>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase("not a url")]
|
||||
[TestCase("ftp://tv.example.com")]
|
||||
[TestCase("http://user:pass@tv.example.com")]
|
||||
[TestCase("http://tv.example.com?foo=bar")]
|
||||
public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid)
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _configElementRepository.DidNotReceive().Upsert(
|
||||
Arg.Any<ConfigElementKey>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Maintenance;
|
||||
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class ReleaseMemoryHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Use_Aggressive_Collection_When_No_Workers_Are_Active()
|
||||
{
|
||||
FFmpegProcess.ProcessCount.ShouldBe(0);
|
||||
|
||||
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
|
||||
segmenterService.Workers.Returns([]);
|
||||
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
|
||||
var handler = new ReleaseMemoryHandler(segmenterService, logger);
|
||||
|
||||
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
|
||||
|
||||
ShouldHaveLogged(logger, "Starting aggressive garbage collection");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Use_Regular_Collection_When_A_Worker_Is_Active()
|
||||
{
|
||||
FFmpegProcess.ProcessCount.ShouldBe(0);
|
||||
|
||||
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
|
||||
segmenterService.Workers.Returns([Substitute.For<IHlsSessionWorker>()]);
|
||||
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
|
||||
var handler = new ReleaseMemoryHandler(segmenterService, logger);
|
||||
|
||||
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
|
||||
|
||||
ShouldHaveLogged(logger, "Starting garbage collection");
|
||||
}
|
||||
|
||||
private static void ShouldHaveLogged(ILogger<ReleaseMemoryHandler> logger, string expectedMessage) =>
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Channel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class ReshufflePlayoutHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private Channel<IBackgroundServiceRequest> _worker = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private ReshufflePlayoutHandler CreateHandler() => new(_mediator, _worker.Writer, _db.Factory);
|
||||
|
||||
private async Task<int> SeedPlayout(PlayoutScheduleKind kind, int? seed = null)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
|
||||
if (seed.HasValue)
|
||||
{
|
||||
playout.Seed = seed.Value;
|
||||
}
|
||||
|
||||
context.Playouts.Add(playout);
|
||||
await context.SaveChangesAsync();
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task Handle_Should_Erase_History_Then_Enqueue_Reset_Build_For_Supported_Kind(
|
||||
PlayoutScheduleKind kind)
|
||||
{
|
||||
int id = await SeedPlayout(kind);
|
||||
|
||||
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ErasePlayoutHistory>(e => e.PlayoutId == id),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
_worker.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var build = request.ShouldBeOfType<BuildPlayout>();
|
||||
build.PlayoutId.ShouldBe(id);
|
||||
build.Mode.ShouldBe(PlayoutBuildMode.Reset);
|
||||
_worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
public async Task Handle_Should_Not_Erase_History_Or_Enqueue_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
int id = await SeedPlayout(kind);
|
||||
|
||||
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
|
||||
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
||||
_worker.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Actually_Reseed_Block_Playout_Via_ErasePlayoutHistory()
|
||||
{
|
||||
// This is the C1 regression test: BuildPlayout(Reset) alone only reseeds Playout.Seed for
|
||||
// Classic playouts (PlayoutBuilder). For Block/Sequential/Scripted it is a no-op reshuffle
|
||||
// unless the handler routes through ErasePlayoutHistory first. Wire the substituted
|
||||
// IMediator to actually invoke the real ErasePlayoutHistoryHandler against the in-memory DB
|
||||
// so the reseed side effect is genuinely exercised, not merely asserted-as-called.
|
||||
const int originalSeed = 12345;
|
||||
int id = await SeedPlayout(PlayoutScheduleKind.Block, originalSeed);
|
||||
|
||||
// seed a PlayoutHistory row so we can also assert the deterministic "cleared" side effect
|
||||
// (avoids relying solely on new Random().Next() != originalSeed, which is a ~1-in-2^31 flake)
|
||||
await using (TvContext seedContext = _db.CreateContext())
|
||||
{
|
||||
seedContext.PlayoutHistory.Add(
|
||||
new PlayoutHistory
|
||||
{
|
||||
PlayoutId = id,
|
||||
Key = "test-collection",
|
||||
When = DateTime.UtcNow,
|
||||
Finish = DateTime.UtcNow
|
||||
});
|
||||
await seedContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_mediator.Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => new ErasePlayoutHistoryHandler(_db.Factory).Handle(
|
||||
(ErasePlayoutHistory)callInfo[0],
|
||||
(CancellationToken)callInfo[1]));
|
||||
|
||||
await CreateHandler().Handle(new ReshufflePlayout(id), CancellationToken.None);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Playout playout = await context.Playouts.SingleAsync(p => p.Id == id);
|
||||
playout.Seed.ShouldNotBe(originalSeed);
|
||||
|
||||
List<PlayoutHistory> remainingHistory = await context.PlayoutHistory
|
||||
.Where(h => h.PlayoutId == id)
|
||||
.ToListAsync();
|
||||
remainingHistory.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,20 @@ public class ApiControllerSecurityTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Local_Library_Detail_Should_Require_Authentication_While_Catalog_List_Remains_Opt_Out()
|
||||
{
|
||||
MethodInfo detailAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetById))
|
||||
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetById)}");
|
||||
MethodInfo listAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetAll))
|
||||
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetAll)}");
|
||||
|
||||
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), detailAction)
|
||||
.ShouldBeTrue("local-library detail exposes server filesystem paths and must stay authenticated");
|
||||
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), listAction)
|
||||
.ShouldBeFalse("the ordinary local-library catalog should retain the read-auth opt-out");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScannerController_Should_Be_Localhost_Only()
|
||||
{
|
||||
@@ -131,6 +145,10 @@ public class ApiControllerSecurityTests
|
||||
|
||||
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
|
||||
|
||||
private static bool EffectiveRequiresAuthentication(Type controllerType, MethodInfo action) =>
|
||||
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any() ||
|
||||
action.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any();
|
||||
|
||||
private static bool IsApiAuthorizationFilterRegisteredGlobally()
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
|
||||
@@ -15,7 +15,6 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
using MediatR;
|
||||
@@ -274,28 +273,6 @@ public class ChannelControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPlaybackSource_Should_Require_Authentication_And_Map_Query()
|
||||
{
|
||||
DateTimeOffset at = new(2026, 7, 14, 20, 0, 0, TimeSpan.Zero);
|
||||
var model = new ChannelPlaybackSourceResponseModel(7, 7, at, at, null, null);
|
||||
_mediator.Send(Arg.Any<GetChannelPlaybackSource>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelPlaybackSourceResponseModel>.Some(model));
|
||||
|
||||
IActionResult result = await _controller.GetPlaybackSource(7, at, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetChannelPlaybackSource>(q => q.ChannelId == 7 && q.At == at),
|
||||
Arg.Any<CancellationToken>());
|
||||
|
||||
MethodInfo method = typeof(ChannelController).GetMethod(nameof(ChannelController.GetPlaybackSource))!;
|
||||
method.GetCustomAttribute<RequiresAuthenticationAttribute>().ShouldNotBeNull();
|
||||
ResponseCacheAttribute cache = method.GetCustomAttribute<ResponseCacheAttribute>()!;
|
||||
cache.NoStore.ShouldBeTrue();
|
||||
cache.Location.ShouldBe(ResponseCacheLocation.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
@@ -507,6 +484,7 @@ public class ChannelControllerTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0);
|
||||
|
||||
private static ChannelDetailResponseModel MakeDetailModel(int id) =>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using ErsatzTV.Serialization;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.OpenApi;
|
||||
@@ -72,32 +71,6 @@ public class OpenApiContractHonestyTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Serialized_Gated_Operations_Should_Name_The_ApiKey_Scheme()
|
||||
{
|
||||
string json = await _document.SerializeAsJsonAsync(
|
||||
OpenApiSpecVersion.OpenApi3_1,
|
||||
CancellationToken.None);
|
||||
using JsonDocument serialized = JsonDocument.Parse(json);
|
||||
|
||||
foreach (JsonProperty path in serialized.RootElement.GetProperty("paths").EnumerateObject())
|
||||
{
|
||||
foreach (JsonProperty operation in path.Value.EnumerateObject())
|
||||
{
|
||||
if (operation.NameEquals("parameters"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement security = operation.Value.GetProperty("security");
|
||||
security.GetArrayLength().ShouldBeGreaterThan(0, $"{operation.Name} {path.Name} should declare security");
|
||||
security[0].TryGetProperty(ApiSecurityOperationTransformer.SchemeName, out JsonElement scopes)
|
||||
.ShouldBeTrue($"{operation.Name} {path.Name} should name the ApiKey scheme after serialization");
|
||||
scopes.ValueKind.ShouldBe(JsonValueKind.Array);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Troubleshoot_Playback_Actions_Should_Carry_Stable_Explicit_OperationIds()
|
||||
{
|
||||
|
||||
@@ -70,6 +70,7 @@ public class PlayoutControllerTests
|
||||
nameof(PlayoutController.EraseItemsAndHistory),
|
||||
"POST",
|
||||
"/api/v1/playouts/{id:int}/erase-items-and-history");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlayoutController.GetItemSchedulingContext),
|
||||
"GET",
|
||||
@@ -152,6 +153,30 @@ public class PlayoutControllerTests
|
||||
result.Page.Single().IsLocked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
|
||||
|
||||
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>()
|
||||
.Value.ShouldBeOfType<PlayoutResponseModel>()
|
||||
.Seed.ShouldBe(4242);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Surface_Seed()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page[0].Seed.ShouldBe(4242);
|
||||
}
|
||||
|
||||
// ----- Erase items / history -----
|
||||
|
||||
[Test]
|
||||
@@ -237,6 +262,62 @@ public class PlayoutControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Reshuffle -----
|
||||
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
|
||||
{
|
||||
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
||||
[TestCase(PlayoutScheduleKind.None)]
|
||||
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic)]
|
||||
[TestCase(PlayoutScheduleKind.Block)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential)]
|
||||
[TestCase(PlayoutScheduleKind.Scripted)]
|
||||
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
||||
|
||||
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- Playout item scheduling context -----
|
||||
|
||||
[Test]
|
||||
@@ -1341,6 +1422,7 @@ public class PlayoutControllerTests
|
||||
new PlayoutBuildStatus(),
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0);
|
||||
|
||||
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
|
||||
@@ -1361,7 +1443,8 @@ public class PlayoutControllerTests
|
||||
vm.BuildStatus.Message),
|
||||
vm.DecoId,
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -242,6 +242,48 @@ public class SettingsControllerTests
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetIptv_Should_Map_Vm_To_Response_Model()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" });
|
||||
|
||||
IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" });
|
||||
|
||||
IActionResult result = await _controller.UpdateIptv(
|
||||
new UpdateIptvSettingsRequest("https://public.example.com"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com"));
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateIptvSettings>(c => c.IptvSettings.BaseUrl == "https://public.example.com"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateIptv_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.UpdateIptv(
|
||||
new UpdateIptvSettingsRequest("not a url"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
|
||||
@@ -32,14 +32,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integrations", "integrations", "{4958D7D8-4791-2CCE-6FFA-082B65933577}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "jellyfin", "jellyfin", "{65793B68-0114-8A23-3D53-9EDEAEBFFD0F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV\Jellyfin.Plugin.ChicoryTV.csproj", "{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ChicoryTV.Tests", "integrations\jellyfin\Jellyfin.Plugin.ChicoryTV.Tests\Jellyfin.Plugin.ChicoryTV.Tests.csproj", "{10235034-68F6-44AC-8C54-4C6F12DAD534}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -323,42 +315,6 @@ Global
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x64.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Release|x86.Build.0 = Release|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -369,8 +325,5 @@ Global
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
{65793B68-0114-8A23-3D53-9EDEAEBFFD0F} = {4958D7D8-4791-2CCE-6FFA-082B65933577}
|
||||
{5E6B1E48-F229-4FE1-8BA6-66994ADBE5A8} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
|
||||
{10235034-68F6-44AC-8C54-4C6F12DAD534} = {65793B68-0114-8A23-3D53-9EDEAEBFFD0F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -11,7 +11,6 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -49,28 +48,6 @@ public class ChannelController(
|
||||
CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
|
||||
|
||||
[HttpGet("/api/v1/channels/{id:int}/playback-source")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Resolve the scheduled playback source for a channel")]
|
||||
[EndpointDescription(
|
||||
"Returns the physical media item and in-file offset selected by the channel schedule at the requested " +
|
||||
"time. This read-only endpoint never starts an ErsatzTV transcoder. at defaults to now.")]
|
||||
[EndpointGroupName("general")]
|
||||
[RequiresAuthentication]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
[ProducesResponseType(typeof(ChannelPlaybackSourceResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPlaybackSource(
|
||||
int id,
|
||||
[FromQuery] DateTimeOffset? at,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ChannelPlaybackSourceResponseModel> result = await mediator.Send(
|
||||
new GetChannelPlaybackSource(id, at ?? DateTimeOffset.UtcNow),
|
||||
cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get available music video credits template names")]
|
||||
@@ -237,6 +214,47 @@ public class ChannelController(
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/channels/auto-tune/preview", Name = "PreviewAutoTuneChannels")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Preview auto-tuned channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<AutoTuneProposalResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> PreviewAutoTune(
|
||||
[Required][FromBody] PreviewAutoTuneChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<AutoTuneProposal>> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: proposals => new OkObjectResult(proposals.Select(ProjectToResponseModel).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/channels/auto-tune", Name = "CreateAutoTunedChannels")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create auto-tuned channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(AutoTuneResultResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CreateAutoTuned(
|
||||
[Required][FromBody] CreateAutoTunedChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AutoTuneResult result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return new OkObjectResult(ProjectToResponseModel(result));
|
||||
}
|
||||
|
||||
private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) =>
|
||||
new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists);
|
||||
|
||||
private static AutoTuneResultResponseModel ProjectToResponseModel(AutoTuneResult r) =>
|
||||
new(
|
||||
r.Results.Select(o => new AutoTuneChannelResultModel(
|
||||
o.Name, o.Status.ToString(), o.ChannelId, o.Reason)).ToList(),
|
||||
r.CreatedCount,
|
||||
r.SkippedCount,
|
||||
r.FailedCount);
|
||||
|
||||
[HttpPost("/api/v1/channels/{id:int}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -40,6 +41,7 @@ public class LocalLibrariesController(
|
||||
[HttpGet("/api/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get a local library by id")]
|
||||
[RequiresAuthentication]
|
||||
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
|
||||
@@ -683,6 +683,45 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/playouts/{id:int}/reshuffle", Name = "ReshufflePlayout")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reshuffle a playout")]
|
||||
[EndpointDescription(
|
||||
"Rolls a new random play order for a Classic, Block, Sequential, or Scripted playout by reseeding it " +
|
||||
"and rebuilding from scratch (clears rerun history). Only valid for those kinds; other kinds return 422.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Reshuffle(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsPlayoutLocked(id))
|
||||
{
|
||||
return PlayoutLockedProblem();
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
|
||||
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[Reshuffle] is only valid for Classic, Block, Sequential, or Scripted playouts")
|
||||
.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Send(new ReshufflePlayout(id), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Decode a playout item's scheduling context")]
|
||||
@@ -786,7 +825,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.DecoId,
|
||||
vm.DecoName,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static PlayoutAlternateScheduleResponseModel ToResponse(PlayoutAlternateScheduleViewModel vm) =>
|
||||
new(
|
||||
@@ -835,7 +875,8 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus),
|
||||
vm.PlayoutMode,
|
||||
isLocked);
|
||||
isLocked,
|
||||
vm.Seed);
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record PreviewAutoTuneChannelsRequest(
|
||||
List<AutoTuneAxis> Axes,
|
||||
int MinItems,
|
||||
int StartingNumber)
|
||||
{
|
||||
public PreviewAutoTuneChannels ToCommand() => new(Axes, MinItems, StartingNumber);
|
||||
}
|
||||
|
||||
public record CreateAutoTunedChannelsRequest(
|
||||
int TemplateId,
|
||||
string Group,
|
||||
List<AutoTunedChannelRequest> Channels)
|
||||
{
|
||||
public CreateAutoTunedChannels ToCommand() =>
|
||||
new(TemplateId, Group, (Channels ?? []).Select(c => c.ToCommand()).ToList());
|
||||
}
|
||||
|
||||
public record AutoTunedChannelRequest(
|
||||
AutoTuneAxis Axis,
|
||||
string Value,
|
||||
string Name,
|
||||
string Number)
|
||||
{
|
||||
public AutoTuneChannelSelection ToCommand() => new(Axis, Value, Name, Number);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
|
||||
/// absolute M3U/XMLTV URLs; send an empty string to clear it and use the incoming request's origin.
|
||||
/// </summary>
|
||||
public record UpdateIptvSettingsRequest(string BaseUrl)
|
||||
{
|
||||
public UpdateIptvSettings ToCommand() =>
|
||||
new(new IptvSettingsViewModel { BaseUrl = BaseUrl });
|
||||
}
|
||||
@@ -121,6 +121,39 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// IPTV settings
|
||||
|
||||
[HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get IPTV output settings")]
|
||||
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<IptvSettingsResponseModel> GetIptv(CancellationToken cancellationToken)
|
||||
{
|
||||
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update IPTV output settings")]
|
||||
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateIptv(
|
||||
[Required] [FromBody]
|
||||
UpdateIptvSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
|
||||
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
|
||||
});
|
||||
}
|
||||
|
||||
// Scanner settings
|
||||
|
||||
[HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")]
|
||||
@@ -274,6 +307,9 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
|
||||
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
|
||||
|
||||
private static IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) =>
|
||||
new(vm.BaseUrl);
|
||||
|
||||
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
|
||||
new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior));
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
|
||||
<NeutralLanguage>en-US</NeutralLanguage>
|
||||
|
||||
@@ -36,11 +36,7 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
// Microsoft.OpenApi 2.x needs the host document to serialize this as the
|
||||
// component name. Without it the in-memory requirement looks populated,
|
||||
// but the generated JSON contains `security: [ {} ]`, which means anonymous
|
||||
// access in OpenAPI rather than the X-Api-Key requirement enforced at runtime.
|
||||
[new OpenApiSecuritySchemeReference(SchemeName, context.Document, null)] = new List<string>()
|
||||
[new OpenApiSecuritySchemeReference(SchemeName)] = new List<string>()
|
||||
});
|
||||
|
||||
operation.Responses ??= new OpenApiResponses();
|
||||
|
||||
+909
-961
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Coverage collector config (ersatztv#15). Passed to dotnet test via
|
||||
"settings coverlet.runsettings" alongside collect:"XPlat Code Coverage".
|
||||
The two EF Core migration folders are ~2.59M lines of generated snapshot code
|
||||
(vs ~200k lines of authored code); instrumenting them balloons coverlet memory
|
||||
and OOM killed the shared Build and test job (exit 137). Excluding generated
|
||||
migration code bounds memory and makes the reported percentage reflect authored
|
||||
code. Keep this comment free of double hyphens (invalid in XML comments).
|
||||
-->
|
||||
<RunSettings>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<ExcludeByFile>**/Migrations/*.cs</ExcludeByFile>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
<Exclude>[*]*.Migrations.*</Exclude>
|
||||
<SkipAutoProps>true</SkipAutoProps>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -0,0 +1,900 @@
|
||||
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
|
||||
// The "automatic-first" creation mode alongside the manual Channel Builder.
|
||||
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
|
||||
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
|
||||
// Additive & non-destructive: never edits or deletes an existing channel; number
|
||||
// or name collisions are skipped, never overwritten. Each generated channel is
|
||||
// backed by a live SmartCollection query so it keeps tracking the library.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
|
||||
// nameOf() and order are display-only here; the real server owns query + name.
|
||||
const AXES = [
|
||||
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
|
||||
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
|
||||
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
|
||||
];
|
||||
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
|
||||
|
||||
// ---- Mock library metadata (EF distinct+count in the real app) ------------
|
||||
const LIBRARY = {
|
||||
TvShow: [
|
||||
{ value: "The Office", count: 201 },
|
||||
{ value: "Friends", count: 236 },
|
||||
{ value: "Breaking Bad", count: 62 },
|
||||
{ value: "Parks and Recreation", count: 125 },
|
||||
{ value: "The Twilight Zone", count: 156 },
|
||||
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
TvGenre: [
|
||||
{ value: "Comedy", count: 640 },
|
||||
{ value: "Drama", count: 512 },
|
||||
{ value: "Sci-Fi", count: 208 },
|
||||
{ value: "Crime", count: 174 },
|
||||
],
|
||||
MovieGenre: [
|
||||
{ value: "Action", count: 42 },
|
||||
{ value: "Sci-Fi", count: 28 },
|
||||
{ value: "Horror", count: 35 },
|
||||
{ value: "Comedy", count: 51 },
|
||||
{ value: "Noir", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
};
|
||||
|
||||
// Coexistence demo: some names already exist (deduped, unchecked by default)
|
||||
// and some numbers are already taken (allocation skips them).
|
||||
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
|
||||
const TAKEN_NUMBERS = new Set(["500", "503"]);
|
||||
|
||||
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
|
||||
|
||||
// ---- Preview computation (advisory numbers, re-validated at create) -------
|
||||
function buildProposals(axisIds, minItems, startingNumber) {
|
||||
const out = [];
|
||||
let next = Math.max(1, startingNumber | 0);
|
||||
const takenThisRun = new Set(TAKEN_NUMBERS);
|
||||
const alloc = () => {
|
||||
while (takenThisRun.has(String(next))) next++;
|
||||
const n = String(next);
|
||||
takenThisRun.add(n);
|
||||
next++;
|
||||
return n;
|
||||
};
|
||||
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
|
||||
AXES.forEach((ax) => {
|
||||
if (!axisIds.has(ax.id)) return;
|
||||
LIBRARY[ax.id]
|
||||
.filter((row) => row.count >= minItems)
|
||||
.slice()
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.forEach((row) => {
|
||||
const name = ax.nameOf(row.value);
|
||||
const exists = EXISTING_NAMES.has(name);
|
||||
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
|
||||
// explicit initials + color (ChannelLogo only derives them from name). --
|
||||
const BUG_PALETTE = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
|
||||
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
|
||||
function Bug({ name, initials, ci, size = 32 }) {
|
||||
const idx = ci != null ? ci : bugHash(name);
|
||||
const [fg, bg] = BUG_PALETTE[idx];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example content sources (advisory sample of the smart-collection query)
|
||||
const GENRE_SHOWS = {
|
||||
Comedy: ["The Office", "Friends", "Parks and Recreation"],
|
||||
Drama: ["Breaking Bad", "The Twilight Zone"],
|
||||
"Sci-Fi": ["The Twilight Zone", "Firefly"],
|
||||
Crime: ["Breaking Bad"],
|
||||
};
|
||||
const MOVIE_TITLES = {
|
||||
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
|
||||
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
|
||||
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
|
||||
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
|
||||
Noir: ["Rain on 5th", "The Long Con"],
|
||||
};
|
||||
const isGenreAxis = (axis) => axis !== "TvShow";
|
||||
function baseSources(p) {
|
||||
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
|
||||
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
|
||||
return []; // TvShow channels track a single show — no per-source editor
|
||||
}
|
||||
function effectiveSources(p, ov) {
|
||||
const ex = ov.exclude || [];
|
||||
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
|
||||
}
|
||||
// Advisory item-count estimate as sources are added/removed (per-source share).
|
||||
function estItems(p, ov) {
|
||||
const base = baseSources(p);
|
||||
if (!base.length) return p.itemCount;
|
||||
const share = p.itemCount / base.length;
|
||||
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
|
||||
}
|
||||
// Sources with their rotation weight (episodes played per rotation).
|
||||
function sourceList(p, ov) {
|
||||
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
|
||||
}
|
||||
|
||||
// Compact rotation-weight stepper (− N +).
|
||||
function Weight({ w, onChange }) {
|
||||
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
|
||||
return (
|
||||
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
|
||||
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
|
||||
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
|
||||
// Effective order for a channel: the axis default, unless overridden per channel.
|
||||
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
|
||||
|
||||
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
|
||||
function buildSchedule(p, ov) {
|
||||
const mins = RUNTIME[p.axis] || 30;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
let seq;
|
||||
if (p.axis === "TvShow") {
|
||||
const n = Math.min(8, Math.max(3, p.itemCount));
|
||||
let eps = Array.from({ length: n }, (_, i) => i + 1);
|
||||
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
|
||||
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
|
||||
} else {
|
||||
const list = sourceList(p, ov).filter((s) => s.weight > 0);
|
||||
if (!list.length) return [];
|
||||
if (shuffled) {
|
||||
// Weighted round-robin interleave — honors the per-source rotation ratio.
|
||||
const total = list.reduce((a, s) => a + s.weight, 0);
|
||||
const st = list.map((s) => ({ ...s, acc: 0 }));
|
||||
const count = Math.min(9, Math.max(5, total * 2));
|
||||
seq = Array.from({ length: count }, () => {
|
||||
let pick = null;
|
||||
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
|
||||
pick.acc -= total;
|
||||
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
|
||||
});
|
||||
} else {
|
||||
// Sequential — each source plays its rotation count in turn.
|
||||
seq = [];
|
||||
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
|
||||
seq = seq.slice(0, 9);
|
||||
}
|
||||
}
|
||||
let t = 20 * 60;
|
||||
return seq.map((b) => {
|
||||
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
|
||||
t += mins;
|
||||
return { ...b, start, mins };
|
||||
});
|
||||
}
|
||||
|
||||
function MiniEpg({ blocks }) {
|
||||
if (!blocks.length) return (
|
||||
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
|
||||
);
|
||||
const PPM = 2.0;
|
||||
return (
|
||||
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
|
||||
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
|
||||
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
|
||||
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, first }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
|
||||
const CH_TEMPLATES = [
|
||||
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
|
||||
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
|
||||
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
|
||||
];
|
||||
const ADV_GROUPS = [
|
||||
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
|
||||
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
|
||||
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
|
||||
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
|
||||
];
|
||||
|
||||
// Friendly toggle row (label + description + Switch), with an override tag.
|
||||
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
|
||||
return (
|
||||
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
|
||||
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
|
||||
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel Template picker (collapsed row + dropdown + spec chips).
|
||||
function TemplatePicker({ value, onPick }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
|
||||
</div>
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{CH_TEMPLATES.map((x) => {
|
||||
const on = x.id === value;
|
||||
return (
|
||||
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
|
||||
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{t.sets.map((s) => (
|
||||
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Advanced (~24-field) override disclosure: View defaults / Override.
|
||||
function AdvancedSettings({ ov, patch, templateName }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
|
||||
const vals = { ...defaults, ...(ov.adv || {}) };
|
||||
const override = mode === "override";
|
||||
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const overridden = Object.keys(ov.adv || {}).length;
|
||||
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
|
||||
</div>
|
||||
{mode !== "closed" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
|
||||
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
|
||||
</div>
|
||||
{ADV_GROUPS.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => override ? (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
|
||||
) : (
|
||||
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
|
||||
function LogoDrop({ name, src, onSet }) {
|
||||
const [over, setOver] = React.useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
|
||||
return (
|
||||
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
|
||||
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
|
||||
</div>
|
||||
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Channel detail slide-over: full per-channel settings -----------------
|
||||
function DetailPanel({ p, ov, patch, onClose }) {
|
||||
const [addText, setAddText] = React.useState("");
|
||||
const name = ov.customName != null ? ov.customName : p.name;
|
||||
const bug = ov.bug || {};
|
||||
const ax = AXIS[p.axis];
|
||||
const number = ov.number != null ? ov.number : p.number;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
const always = ov.always != null ? ov.always : true;
|
||||
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
|
||||
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
|
||||
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
|
||||
const avatar = (size) => bug.src
|
||||
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
|
||||
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
|
||||
const genre = isGenreAxis(p.axis);
|
||||
const base = baseSources(p);
|
||||
const ex = ov.exclude || [];
|
||||
const inc = ov.include || [];
|
||||
const kept = base.filter((s) => !ex.includes(s));
|
||||
const items = estItems(p, ov);
|
||||
const schedule = buildSchedule(p, ov);
|
||||
const ratios = ov.ratios || {};
|
||||
const wOf = (s) => ratios[s] || 1;
|
||||
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
|
||||
const list = [...kept, ...inc];
|
||||
const multi = list.length > 1;
|
||||
|
||||
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
|
||||
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
|
||||
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
|
||||
const addInc = () => {
|
||||
const v = addText.trim();
|
||||
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
|
||||
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
|
||||
setAddText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
|
||||
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
|
||||
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{avatar(34)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
|
||||
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Channel identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
|
||||
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
|
||||
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</label>
|
||||
</div>
|
||||
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
||||
{avatar(48)}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
|
||||
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
{BUG_PALETTE.map((pair, i) => {
|
||||
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
|
||||
return (
|
||||
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
|
||||
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
|
||||
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={eyebrow}>Playback</div>
|
||||
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
|
||||
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Channel Template</div>
|
||||
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Query & size</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
|
||||
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
|
||||
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
|
||||
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
|
||||
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{genre && (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Content sources</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
|
||||
</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{list.length === 0 && (
|
||||
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
|
||||
)}
|
||||
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
|
||||
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
|
||||
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
|
||||
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
|
||||
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
|
||||
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{multi && (
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
|
||||
</div>
|
||||
{ex.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
|
||||
{ex.map((s) => (
|
||||
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
|
||||
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Example schedule</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
|
||||
</div>
|
||||
<MiniEpg blocks={schedule} />
|
||||
</section>
|
||||
|
||||
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
function AutoTune() {
|
||||
const [step, setStep] = React.useState("configure"); // configure | preview | create
|
||||
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
|
||||
const [minItems, setMinItems] = React.useState("5");
|
||||
const [startingNumber, setStartingNumber] = React.useState("500");
|
||||
const [group, setGroup] = React.useState("Auto-Tuned");
|
||||
const [template, setTemplate] = React.useState("Standard");
|
||||
|
||||
const [proposals, setProposals] = React.useState([]);
|
||||
const [selected, setSelected] = React.useState(() => new Set());
|
||||
const [results, setResults] = React.useState(null);
|
||||
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
|
||||
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
|
||||
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
|
||||
|
||||
const patchOv = (name) => (patch) => setOverrides((o) => {
|
||||
const cur = o[name] || {};
|
||||
const delta = typeof patch === "function" ? patch(cur) : patch;
|
||||
return { ...o, [name]: { ...cur, ...delta } };
|
||||
});
|
||||
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
|
||||
|
||||
const toggleAxis = (id) =>
|
||||
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
|
||||
const runPreview = () => {
|
||||
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
|
||||
setProposals(p);
|
||||
// Default selection: everything that isn't an already-existing name.
|
||||
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
|
||||
setStep("preview");
|
||||
};
|
||||
|
||||
const runCreate = () => {
|
||||
// Demo the three outcome states across the selected set.
|
||||
const chosen = proposals.filter((p) => selected.has(p.name));
|
||||
const res = chosen.map((p, i) => {
|
||||
const nm = dispName(p);
|
||||
if (i === chosen.length - 1 && chosen.length > 2)
|
||||
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
|
||||
if (p.name === "Sci-Fi Movies")
|
||||
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
|
||||
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
|
||||
});
|
||||
setResults(res);
|
||||
setStep("create");
|
||||
};
|
||||
|
||||
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<Toolbar
|
||||
step={step}
|
||||
axisCount={axisIds.size}
|
||||
selectedCount={selected.size}
|
||||
onPreview={runPreview}
|
||||
onCreate={runCreate}
|
||||
onBack={() => setStep("configure")}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{step === "configure" && (
|
||||
<Configure
|
||||
axisIds={axisIds} toggleAxis={toggleAxis}
|
||||
minItems={minItems} setMinItems={setMinItems}
|
||||
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
|
||||
group={group} setGroup={setGroup}
|
||||
template={template} setTemplate={setTemplate}
|
||||
/>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
|
||||
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
|
||||
openDetail={setDetail} dispName={dispName} />
|
||||
)}
|
||||
{step === "create" && <Results results={results} group={group} />}
|
||||
</main>
|
||||
{detail != null && (() => {
|
||||
const p = proposals.find((x) => x.name === detail);
|
||||
if (!p) return null;
|
||||
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Toolbar with step rail + contextual primary action -------------------
|
||||
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
|
||||
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
<StepRail step={step} />
|
||||
</div>
|
||||
{step === "configure" && (
|
||||
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
|
||||
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<React.Fragment>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
|
||||
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
|
||||
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
|
||||
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{step === "create" && (
|
||||
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: "configure", label: "Configure" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "create", label: "Create" },
|
||||
];
|
||||
function StepRail({ step }) {
|
||||
const idx = STEPS.findIndex((s) => s.id === step);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < idx, active = i === idx;
|
||||
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
|
||||
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
|
||||
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
|
||||
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
|
||||
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 1: Configure ----------------------------------------------------
|
||||
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
|
||||
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
|
||||
preview the proposed channels, then create the ones you want. Existing channels are never touched.
|
||||
</p>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Generate from</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
{AXES.map((ax) => {
|
||||
const on = axisIds.has(ax.id);
|
||||
return (
|
||||
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
|
||||
className="ctv-press"
|
||||
style={{
|
||||
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
|
||||
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
|
||||
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
|
||||
}}>
|
||||
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
|
||||
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Defaults</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
|
||||
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
|
||||
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
|
||||
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel group" hint="Every generated channel lands in this group.">
|
||||
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
|
||||
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
|
||||
{children}
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 2: Preview ------------------------------------------------------
|
||||
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
|
||||
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
|
||||
const selectable = proposals.filter((p) => !p.alreadyExists);
|
||||
const existingCount = proposals.length - selectable.length;
|
||||
|
||||
if (!proposals.length) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
|
||||
<Ico n="SearchX" s={26} />
|
||||
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setAll = (rows, on) => setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
|
||||
</span>
|
||||
{existingCount > 0 && (
|
||||
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
|
||||
</div>
|
||||
|
||||
{groups.map(({ ax, rows }) => {
|
||||
const groupSel = rows.filter((r) => !r.alreadyExists);
|
||||
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
|
||||
const someOn = groupSel.some((r) => selected.has(r.name));
|
||||
return (
|
||||
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
|
||||
<Ico n={ax.icon} s={15} />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
|
||||
<Badge tone="neutral">{rows.length}</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((r, i) => {
|
||||
const on = selected.has(r.name);
|
||||
const ov = overrides[r.name] || {};
|
||||
const name = dispName(r);
|
||||
const isExp = expanded.has(r.name);
|
||||
return (
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
|
||||
</span>
|
||||
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span> items</Badge>
|
||||
{r.alreadyExists ? (
|
||||
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
|
||||
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
|
||||
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
|
||||
</IconButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{isExp && !r.alreadyExists && (
|
||||
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={eyebrow}>Example schedule</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
|
||||
</div>
|
||||
<MiniEpg blocks={buildSchedule(r, ov)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 3: Results ------------------------------------------------------
|
||||
const RESULT_META = {
|
||||
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
|
||||
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
|
||||
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
|
||||
};
|
||||
function Results({ results, group }) {
|
||||
const count = (s) => results.filter((r) => r.status === s).length;
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
|
||||
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
|
||||
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
|
||||
</div>
|
||||
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
{results.map((r, i) => {
|
||||
const m = RESULT_META[r.status];
|
||||
return (
|
||||
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
|
||||
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
|
||||
</div>
|
||||
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
|
||||
<Badge tone={m.tone}>{r.status}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVAutoTune = AutoTune;
|
||||
})();
|
||||
@@ -0,0 +1,167 @@
|
||||
# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
|
||||
|
||||
## Overview
|
||||
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
|
||||
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
|
||||
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
|
||||
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
|
||||
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
|
||||
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
|
||||
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
|
||||
|
||||
Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
|
||||
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
|
||||
lineup on every run. Ours adds a preview/select step and is non-destructive.
|
||||
|
||||
The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
|
||||
handoff is **PR2: the SPA screen** that drives them.
|
||||
|
||||
> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
|
||||
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
|
||||
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
|
||||
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
|
||||
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
|
||||
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
|
||||
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
|
||||
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
|
||||
> #383 arc, not PR2.
|
||||
|
||||
## About the design files
|
||||
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
|
||||
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
|
||||
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
|
||||
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
|
||||
its mock data and mock outcome logic are illustrative only — the real screen calls the API.
|
||||
|
||||
## Fidelity
|
||||
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
|
||||
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
|
||||
are exact.
|
||||
|
||||
---
|
||||
|
||||
## The screen — a 3-step wizard
|
||||
|
||||
One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
|
||||
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
|
||||
primary action change per step.
|
||||
|
||||
**Toolbar (all steps):**
|
||||
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
|
||||
`color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
|
||||
semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
|
||||
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
|
||||
connectors. Each step is a 20px round chip + label. The **active** step: chip
|
||||
`background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
|
||||
**completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
|
||||
`--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
|
||||
- Right: the contextual primary action (below).
|
||||
|
||||
### Step 1 — Configure
|
||||
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
|
||||
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
|
||||
the non-destructive promise.
|
||||
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
|
||||
(one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
|
||||
- Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
|
||||
a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
|
||||
`var(--text-on-accent)`.
|
||||
- Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
|
||||
top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
|
||||
- Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
|
||||
**order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
|
||||
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
|
||||
(`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
|
||||
- **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
|
||||
- **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
|
||||
- **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
|
||||
- **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
|
||||
|
||||
**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
|
||||
axis is selected.
|
||||
|
||||
### Step 2 — Preview
|
||||
Centered column, `max-width: 920px`.
|
||||
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
|
||||
when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
|
||||
only touch selectable — non-existing — rows).
|
||||
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
|
||||
`var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
|
||||
(`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
|
||||
the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
|
||||
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
|
||||
`min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
|
||||
neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
|
||||
Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
|
||||
re-create a channel that already exists by that name).
|
||||
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
|
||||
|
||||
**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
|
||||
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
|
||||
|
||||
### Step 3 — Create (results)
|
||||
Centered column, `max-width: 860px`.
|
||||
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
|
||||
`CheckCircle2` / `MinusCircle` / `XCircle`).
|
||||
- A **"Added to group {group}"** line (`FolderTree` icon).
|
||||
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
|
||||
(`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
|
||||
**reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
|
||||
status **Badge** (`positive` / `neutral` / `danger`).
|
||||
|
||||
**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
|
||||
|
||||
---
|
||||
|
||||
## API mapping (the real screen)
|
||||
|
||||
Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
|
||||
query; the server regenerates it (query authorship is server-side only).
|
||||
|
||||
| UI element | Endpoint / field |
|
||||
|---|---|
|
||||
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
|
||||
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
|
||||
| — Minimum items | request `minItems: number` |
|
||||
| — Starting channel number | request `startingNumber: number` |
|
||||
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
|
||||
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
|
||||
| — `{itemCount} items` badge | `itemCount` |
|
||||
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
|
||||
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
|
||||
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
|
||||
| — Channel group field | request `group: string` |
|
||||
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
|
||||
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
|
||||
|
||||
Notes:
|
||||
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
|
||||
**re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
|
||||
batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
|
||||
- **Default selection = every proposal whose `alreadyExists` is false.**
|
||||
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
|
||||
server's ordering rather than re-sorting client-side.
|
||||
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
|
||||
template) so the field is never empty; the batch requires a `templateId`.
|
||||
|
||||
## Real-SPA implementation notes (target = `web/`)
|
||||
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
|
||||
label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
|
||||
group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
|
||||
action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
|
||||
*create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
|
||||
button for this screen.
|
||||
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
|
||||
`createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
|
||||
re-exported from `web/src/api/index.ts`.
|
||||
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
|
||||
for the template `Select`.
|
||||
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
|
||||
`activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
|
||||
not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
|
||||
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
|
||||
`Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
|
||||
small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
|
||||
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
|
||||
`docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
|
||||
(if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
|
||||
@@ -0,0 +1,241 @@
|
||||
// API Key screen — machine key management and local admin password change.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Card, Input, Spinner } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
// Mock data
|
||||
const MACHINE_KEY = "etv_0b4f8c2a1e9d5f3b7a6c4e2d0f1a8b9c5d3e2f1a0b4c6d8e9f0a1b2c3d4e5f6g";
|
||||
const mockAuthMethod = "local"; // 'local' or 'oidc' — determines if password card shows
|
||||
|
||||
function CardFrame({ title, subtitle, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: "var(--surface-card)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "var(--pad-card)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12
|
||||
}}>
|
||||
{title && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineKeyCard() {
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(MACHINE_KEY).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Machine API Key"
|
||||
subtitle="This key authenticates MCP and external REST clients. The browser no longer uses it — you sign in with your account instead."
|
||||
>
|
||||
{/* Success callout */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-ok-soft)",
|
||||
border: "1px solid var(--status-ok)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
<Ico n="ShieldCheck" s={15} color="var(--status-ok)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
Give this key to an MCP server or external REST client to let it call this server's API.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Machine key field */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<label style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-secondary)" }}>
|
||||
Machine key
|
||||
</label>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-bg-sunken)",
|
||||
border: "1px solid var(--border-control)",
|
||||
fontFamily: "var(--font-mono)"
|
||||
}}>
|
||||
<Ico n="KeyRound" s={14} color="var(--text-disabled)" />
|
||||
<code style={{
|
||||
flex: 1,
|
||||
overflowX: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
font: "var(--text-sm)/1.3 var(--font-mono)",
|
||||
color: "var(--text-primary)",
|
||||
margin: 0,
|
||||
background: "none"
|
||||
}}>
|
||||
{revealed ? MACHINE_KEY : "••••••••••••••••••••••••"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
startIcon={revealed ? <Ico n="EyeOff" s={14} /> : <Ico n="Eye" s={14} />}
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
>
|
||||
{revealed ? "Hide" : "Reveal"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
startIcon={copied ? <Ico n="Check" s={14} /> : <Ico n="Copy" s={14} />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalPasswordCard() {
|
||||
const [currentPassword, setCurrentPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [justSaved, setJustSaved] = React.useState(false);
|
||||
|
||||
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && !saving;
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setJustSaved(false);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setJustSaved(true);
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Local admin password"
|
||||
subtitle="Change the password for your local admin account."
|
||||
>
|
||||
{error && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "12px 14px",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--ctv-warn-soft)",
|
||||
border: "1px solid var(--status-warn)",
|
||||
color: "var(--text-primary)"
|
||||
}}>
|
||||
<Ico n="TriangleAlert" s={15} color="var(--status-warn)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)" }}>
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: error ? 12 : 0 }}>
|
||||
<Input
|
||||
label="Current password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => {
|
||||
setCurrentPassword(e.target.value);
|
||||
setJustSaved(false);
|
||||
}}
|
||||
leadingIcon={<Ico n="Lock" s={14} />}
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
<Input
|
||||
label="New password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
setJustSaved(false);
|
||||
}}
|
||||
leadingIcon={<Ico n="Lock" s={14} />}
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginTop: 16 }}>
|
||||
{justSaved && (
|
||||
<span style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)",
|
||||
color: "var(--status-ok)"
|
||||
}}>
|
||||
<Ico n="Check" s={14} />
|
||||
Password changed.
|
||||
</span>
|
||||
)}
|
||||
{!justSaved && <span />}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
startIcon={<Ico n="Check" s={14} />}
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={saving}
|
||||
>
|
||||
{saving ? "Changing…" : "Change password"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKey() {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
gap: 16,
|
||||
padding: "20px",
|
||||
overflow: "auto"
|
||||
}}>
|
||||
<MachineKeyCard />
|
||||
{mockAuthMethod === "local" && <LocalPasswordCard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVApiKey = ApiKey;
|
||||
})();
|
||||
@@ -0,0 +1,900 @@
|
||||
// Auto-Tune — generate a whole channel lineup from library metadata (#69).
|
||||
// The "automatic-first" creation mode alongside the manual Channel Builder.
|
||||
// One screen, three steps: Configure axes/defaults -> Preview proposed channels
|
||||
// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
|
||||
// Additive & non-destructive: never edits or deletes an existing channel; number
|
||||
// or name collisions are skipped, never overwritten. Each generated channel is
|
||||
// backed by a live SmartCollection query so it keeps tracking the library.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
|
||||
// nameOf() and order are display-only here; the real server owns query + name.
|
||||
const AXES = [
|
||||
{ id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
|
||||
{ id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
|
||||
{ id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
|
||||
];
|
||||
const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
|
||||
|
||||
// ---- Mock library metadata (EF distinct+count in the real app) ------------
|
||||
const LIBRARY = {
|
||||
TvShow: [
|
||||
{ value: "The Office", count: 201 },
|
||||
{ value: "Friends", count: 236 },
|
||||
{ value: "Breaking Bad", count: 62 },
|
||||
{ value: "Parks and Recreation", count: 125 },
|
||||
{ value: "The Twilight Zone", count: 156 },
|
||||
{ value: "Firefly", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
TvGenre: [
|
||||
{ value: "Comedy", count: 640 },
|
||||
{ value: "Drama", count: 512 },
|
||||
{ value: "Sci-Fi", count: 208 },
|
||||
{ value: "Crime", count: 174 },
|
||||
],
|
||||
MovieGenre: [
|
||||
{ value: "Action", count: 42 },
|
||||
{ value: "Sci-Fi", count: 28 },
|
||||
{ value: "Horror", count: 35 },
|
||||
{ value: "Comedy", count: 51 },
|
||||
{ value: "Noir", count: 3 }, // below default minItems -> filtered
|
||||
],
|
||||
};
|
||||
|
||||
// Coexistence demo: some names already exist (deduped, unchecked by default)
|
||||
// and some numbers are already taken (allocation skips them).
|
||||
const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
|
||||
const TAKEN_NUMBERS = new Set(["500", "503"]);
|
||||
|
||||
const TEMPLATES = ["Standard", "Movie night", "Music videos"];
|
||||
|
||||
// ---- Preview computation (advisory numbers, re-validated at create) -------
|
||||
function buildProposals(axisIds, minItems, startingNumber) {
|
||||
const out = [];
|
||||
let next = Math.max(1, startingNumber | 0);
|
||||
const takenThisRun = new Set(TAKEN_NUMBERS);
|
||||
const alloc = () => {
|
||||
while (takenThisRun.has(String(next))) next++;
|
||||
const n = String(next);
|
||||
takenThisRun.add(n);
|
||||
next++;
|
||||
return n;
|
||||
};
|
||||
// Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
|
||||
AXES.forEach((ax) => {
|
||||
if (!axisIds.has(ax.id)) return;
|
||||
LIBRARY[ax.id]
|
||||
.filter((row) => row.count >= minItems)
|
||||
.slice()
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.forEach((row) => {
|
||||
const name = ax.nameOf(row.value);
|
||||
const exists = EXISTING_NAMES.has(name);
|
||||
out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
|
||||
// explicit initials + color (ChannelLogo only derives them from name). --
|
||||
const BUG_PALETTE = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
|
||||
const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
|
||||
function Bug({ name, initials, ci, size = 32 }) {
|
||||
const idx = ci != null ? ci : bugHash(name);
|
||||
const [fg, bg] = BUG_PALETTE[idx];
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example content sources (advisory sample of the smart-collection query)
|
||||
const GENRE_SHOWS = {
|
||||
Comedy: ["The Office", "Friends", "Parks and Recreation"],
|
||||
Drama: ["Breaking Bad", "The Twilight Zone"],
|
||||
"Sci-Fi": ["The Twilight Zone", "Firefly"],
|
||||
Crime: ["Breaking Bad"],
|
||||
};
|
||||
const MOVIE_TITLES = {
|
||||
Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
|
||||
"Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
|
||||
Horror: ["Hollow", "The Vigil", "Saltmarsh"],
|
||||
Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
|
||||
Noir: ["Rain on 5th", "The Long Con"],
|
||||
};
|
||||
const isGenreAxis = (axis) => axis !== "TvShow";
|
||||
function baseSources(p) {
|
||||
if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
|
||||
if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
|
||||
return []; // TvShow channels track a single show — no per-source editor
|
||||
}
|
||||
function effectiveSources(p, ov) {
|
||||
const ex = ov.exclude || [];
|
||||
return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
|
||||
}
|
||||
// Advisory item-count estimate as sources are added/removed (per-source share).
|
||||
function estItems(p, ov) {
|
||||
const base = baseSources(p);
|
||||
if (!base.length) return p.itemCount;
|
||||
const share = p.itemCount / base.length;
|
||||
return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
|
||||
}
|
||||
// Sources with their rotation weight (episodes played per rotation).
|
||||
function sourceList(p, ov) {
|
||||
return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
|
||||
}
|
||||
|
||||
// Compact rotation-weight stepper (− N +).
|
||||
function Weight({ w, onChange }) {
|
||||
const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
|
||||
return (
|
||||
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
|
||||
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
|
||||
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Example schedule (rundown → EPG blocks), advisory only ---------------
|
||||
// Effective order for a channel: the axis default, unless overridden per channel.
|
||||
function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
|
||||
|
||||
const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
|
||||
function buildSchedule(p, ov) {
|
||||
const mins = RUNTIME[p.axis] || 30;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
let seq;
|
||||
if (p.axis === "TvShow") {
|
||||
const n = Math.min(8, Math.max(3, p.itemCount));
|
||||
let eps = Array.from({ length: n }, (_, i) => i + 1);
|
||||
if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
|
||||
seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
|
||||
} else {
|
||||
const list = sourceList(p, ov).filter((s) => s.weight > 0);
|
||||
if (!list.length) return [];
|
||||
if (shuffled) {
|
||||
// Weighted round-robin interleave — honors the per-source rotation ratio.
|
||||
const total = list.reduce((a, s) => a + s.weight, 0);
|
||||
const st = list.map((s) => ({ ...s, acc: 0 }));
|
||||
const count = Math.min(9, Math.max(5, total * 2));
|
||||
seq = Array.from({ length: count }, () => {
|
||||
let pick = null;
|
||||
st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
|
||||
pick.acc -= total;
|
||||
return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
|
||||
});
|
||||
} else {
|
||||
// Sequential — each source plays its rotation count in turn.
|
||||
seq = [];
|
||||
list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
|
||||
seq = seq.slice(0, 9);
|
||||
}
|
||||
}
|
||||
let t = 20 * 60;
|
||||
return seq.map((b) => {
|
||||
const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
|
||||
t += mins;
|
||||
return { ...b, start, mins };
|
||||
});
|
||||
}
|
||||
|
||||
function MiniEpg({ blocks }) {
|
||||
if (!blocks.length) return (
|
||||
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
|
||||
);
|
||||
const PPM = 2.0;
|
||||
return (
|
||||
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
|
||||
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
|
||||
{blocks.map((b, i) => (
|
||||
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
|
||||
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
|
||||
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigRow({ label, value, first }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Full channel settings (mirrors the manual Channel Builder) -----------
|
||||
const CH_TEMPLATES = [
|
||||
{ id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
|
||||
{ id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
|
||||
{ id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
|
||||
];
|
||||
const ADV_GROUPS = [
|
||||
{ group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
|
||||
{ group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
|
||||
{ group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
|
||||
{ group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
|
||||
];
|
||||
|
||||
// Friendly toggle row (label + description + Switch), with an override tag.
|
||||
function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
|
||||
return (
|
||||
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
|
||||
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
|
||||
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel Template picker (collapsed row + dropdown + spec chips).
|
||||
function TemplatePicker({ value, onPick }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
|
||||
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
|
||||
</div>
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{CH_TEMPLATES.map((x) => {
|
||||
const on = x.id === value;
|
||||
return (
|
||||
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
|
||||
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
|
||||
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
|
||||
{t.sets.map((s) => (
|
||||
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Advanced (~24-field) override disclosure: View defaults / Override.
|
||||
function AdvancedSettings({ ov, patch, templateName }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
|
||||
const vals = { ...defaults, ...(ov.adv || {}) };
|
||||
const override = mode === "override";
|
||||
const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||||
const overridden = Object.keys(ov.adv || {}).length;
|
||||
const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
|
||||
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
|
||||
</div>
|
||||
{mode !== "closed" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
|
||||
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
|
||||
</div>
|
||||
{ADV_GROUPS.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => override ? (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
|
||||
) : (
|
||||
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
|
||||
function LogoDrop({ name, src, onSet }) {
|
||||
const [over, setOver] = React.useState(false);
|
||||
const fileRef = React.useRef(null);
|
||||
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
|
||||
return (
|
||||
<div onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
|
||||
onClick={() => fileRef.current && fileRef.current.click()}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
|
||||
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
|
||||
</div>
|
||||
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Channel detail slide-over: full per-channel settings -----------------
|
||||
function DetailPanel({ p, ov, patch, onClose }) {
|
||||
const [addText, setAddText] = React.useState("");
|
||||
const name = ov.customName != null ? ov.customName : p.name;
|
||||
const bug = ov.bug || {};
|
||||
const ax = AXIS[p.axis];
|
||||
const number = ov.number != null ? ov.number : p.number;
|
||||
const shuffled = isShuffled(p, ov);
|
||||
const always = ov.always != null ? ov.always : true;
|
||||
const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
|
||||
const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
|
||||
const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
|
||||
const avatar = (size) => bug.src
|
||||
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
|
||||
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
|
||||
const genre = isGenreAxis(p.axis);
|
||||
const base = baseSources(p);
|
||||
const ex = ov.exclude || [];
|
||||
const inc = ov.include || [];
|
||||
const kept = base.filter((s) => !ex.includes(s));
|
||||
const items = estItems(p, ov);
|
||||
const schedule = buildSchedule(p, ov);
|
||||
const ratios = ov.ratios || {};
|
||||
const wOf = (s) => ratios[s] || 1;
|
||||
const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
|
||||
const list = [...kept, ...inc];
|
||||
const multi = list.length > 1;
|
||||
|
||||
const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
|
||||
const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
|
||||
const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
|
||||
const addInc = () => {
|
||||
const v = addText.trim();
|
||||
if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
|
||||
patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
|
||||
setAddText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
|
||||
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
|
||||
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{avatar(34)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
|
||||
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Channel identity</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
|
||||
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
|
||||
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</label>
|
||||
</div>
|
||||
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
|
||||
{avatar(48)}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
|
||||
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
{BUG_PALETTE.map((pair, i) => {
|
||||
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
|
||||
return (
|
||||
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
|
||||
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
|
||||
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={eyebrow}>Playback</div>
|
||||
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
|
||||
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Channel Template</div>
|
||||
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={eyebrow}>Query & size</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
|
||||
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
|
||||
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
|
||||
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
|
||||
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{genre && (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Content sources</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
|
||||
</div>
|
||||
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
{list.length === 0 && (
|
||||
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
|
||||
)}
|
||||
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
|
||||
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
|
||||
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
|
||||
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
|
||||
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
|
||||
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{multi && (
|
||||
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
|
||||
</div>
|
||||
{ex.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
|
||||
{ex.map((s) => (
|
||||
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
|
||||
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div style={eyebrow}>Example schedule</div>
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
|
||||
</div>
|
||||
<MiniEpg blocks={schedule} />
|
||||
</section>
|
||||
|
||||
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
function AutoTune() {
|
||||
const [step, setStep] = React.useState("configure"); // configure | preview | create
|
||||
const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
|
||||
const [minItems, setMinItems] = React.useState("5");
|
||||
const [startingNumber, setStartingNumber] = React.useState("500");
|
||||
const [group, setGroup] = React.useState("Auto-Tuned");
|
||||
const [template, setTemplate] = React.useState("Standard");
|
||||
|
||||
const [proposals, setProposals] = React.useState([]);
|
||||
const [selected, setSelected] = React.useState(() => new Set());
|
||||
const [results, setResults] = React.useState(null);
|
||||
const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
|
||||
const [detail, setDetail] = React.useState(null); // proposal name open in the panel
|
||||
const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
|
||||
|
||||
const patchOv = (name) => (patch) => setOverrides((o) => {
|
||||
const cur = o[name] || {};
|
||||
const delta = typeof patch === "function" ? patch(cur) : patch;
|
||||
return { ...o, [name]: { ...cur, ...delta } };
|
||||
});
|
||||
const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
|
||||
|
||||
const toggleAxis = (id) =>
|
||||
setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
|
||||
const runPreview = () => {
|
||||
const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
|
||||
setProposals(p);
|
||||
// Default selection: everything that isn't an already-existing name.
|
||||
setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
|
||||
setStep("preview");
|
||||
};
|
||||
|
||||
const runCreate = () => {
|
||||
// Demo the three outcome states across the selected set.
|
||||
const chosen = proposals.filter((p) => selected.has(p.name));
|
||||
const res = chosen.map((p, i) => {
|
||||
const nm = dispName(p);
|
||||
if (i === chosen.length - 1 && chosen.length > 2)
|
||||
return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
|
||||
if (p.name === "Sci-Fi Movies")
|
||||
return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
|
||||
return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
|
||||
});
|
||||
setResults(res);
|
||||
setStep("create");
|
||||
};
|
||||
|
||||
const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
|
||||
<Toolbar
|
||||
step={step}
|
||||
axisCount={axisIds.size}
|
||||
selectedCount={selected.size}
|
||||
onPreview={runPreview}
|
||||
onCreate={runCreate}
|
||||
onBack={() => setStep("configure")}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
|
||||
{step === "configure" && (
|
||||
<Configure
|
||||
axisIds={axisIds} toggleAxis={toggleAxis}
|
||||
minItems={minItems} setMinItems={setMinItems}
|
||||
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
|
||||
group={group} setGroup={setGroup}
|
||||
template={template} setTemplate={setTemplate}
|
||||
/>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
|
||||
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
|
||||
openDetail={setDetail} dispName={dispName} />
|
||||
)}
|
||||
{step === "create" && <Results results={results} group={group} />}
|
||||
</main>
|
||||
{detail != null && (() => {
|
||||
const p = proposals.find((x) => x.name === detail);
|
||||
if (!p) return null;
|
||||
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Toolbar with step rail + contextual primary action -------------------
|
||||
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
|
||||
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
<StepRail step={step} />
|
||||
</div>
|
||||
{step === "configure" && (
|
||||
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
|
||||
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{step === "preview" && (
|
||||
<React.Fragment>
|
||||
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
|
||||
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
|
||||
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
|
||||
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{step === "create" && (
|
||||
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ id: "configure", label: "Configure" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "create", label: "Create" },
|
||||
];
|
||||
function StepRail({ step }) {
|
||||
const idx = STEPS.findIndex((s) => s.id === step);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < idx, active = i === idx;
|
||||
const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
|
||||
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
|
||||
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
|
||||
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
|
||||
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
|
||||
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 1: Configure ----------------------------------------------------
|
||||
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
|
||||
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
|
||||
preview the proposed channels, then create the ones you want. Existing channels are never touched.
|
||||
</p>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Generate from</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
{AXES.map((ax) => {
|
||||
const on = axisIds.has(ax.id);
|
||||
return (
|
||||
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
|
||||
className="ctv-press"
|
||||
style={{
|
||||
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
|
||||
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
|
||||
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
|
||||
}}>
|
||||
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
|
||||
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
|
||||
</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={eyebrow}>Defaults</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
|
||||
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
|
||||
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
|
||||
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel group" hint="Every generated channel lands in this group.">
|
||||
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
|
||||
</Field>
|
||||
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
|
||||
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
|
||||
{children}
|
||||
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 2: Preview ------------------------------------------------------
|
||||
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
|
||||
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
|
||||
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
|
||||
const selectable = proposals.filter((p) => !p.alreadyExists);
|
||||
const existingCount = proposals.length - selectable.length;
|
||||
|
||||
if (!proposals.length) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
|
||||
<Ico n="SearchX" s={26} />
|
||||
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setAll = (rows, on) => setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
|
||||
</span>
|
||||
{existingCount > 0 && (
|
||||
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
|
||||
</div>
|
||||
|
||||
{groups.map(({ ax, rows }) => {
|
||||
const groupSel = rows.filter((r) => !r.alreadyExists);
|
||||
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
|
||||
const someOn = groupSel.some((r) => selected.has(r.name));
|
||||
return (
|
||||
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
|
||||
<Ico n={ax.icon} s={15} />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
|
||||
<Badge tone="neutral">{rows.length}</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((r, i) => {
|
||||
const on = selected.has(r.name);
|
||||
const ov = overrides[r.name] || {};
|
||||
const name = dispName(r);
|
||||
const isExp = expanded.has(r.name);
|
||||
return (
|
||||
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
|
||||
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
|
||||
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
|
||||
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
|
||||
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
|
||||
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
|
||||
</span>
|
||||
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span> items</Badge>
|
||||
{r.alreadyExists ? (
|
||||
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
|
||||
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
|
||||
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
|
||||
</IconButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
{isExp && !r.alreadyExists && (
|
||||
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={eyebrow}>Example schedule</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
|
||||
</div>
|
||||
<MiniEpg blocks={buildSchedule(r, ov)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 3: Results ------------------------------------------------------
|
||||
const RESULT_META = {
|
||||
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
|
||||
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
|
||||
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
|
||||
};
|
||||
function Results({ results, group }) {
|
||||
const count = (s) => results.filter((r) => r.status === s).length;
|
||||
return (
|
||||
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
|
||||
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
|
||||
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
|
||||
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
|
||||
</div>
|
||||
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
|
||||
{results.map((r, i) => {
|
||||
const m = RESULT_META[r.status];
|
||||
return (
|
||||
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
|
||||
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
|
||||
</div>
|
||||
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
|
||||
<Badge tone={m.tone}>{r.status}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVAutoTune = AutoTune;
|
||||
})();
|
||||
@@ -0,0 +1,228 @@
|
||||
// Block Playout Troubleshooting screen — playout picker → grouped block table →
|
||||
// per-block history log → decoded history-row detail panel. A drill-down diagnostic
|
||||
// tool, not a management surface: dense tables, monospace keys, quiet chrome.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Card, Input, Select } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const PLAYOUTS = [
|
||||
{ value: "", label: "Select a block playout…" },
|
||||
{ value: "12", label: "1.1 - WeekdayMornings" },
|
||||
{ value: "18", label: "4.2 - Retro80s" },
|
||||
{ value: "23", label: "9.1 - KidsAllDay" },
|
||||
];
|
||||
|
||||
const BLOCKS = [
|
||||
{ id: 101, group: "Morning", name: "Wake Up Block", minutes: 60 },
|
||||
{ id: 102, group: "Morning", name: "Cartoon Hour", minutes: 60 },
|
||||
{ id: 103, group: "Morning", name: "News Bridge", minutes: 30 },
|
||||
{ id: 201, group: "Afternoon", name: "Sitcom Rerun Block", minutes: 90 },
|
||||
{ id: 202, group: "Afternoon", name: "Movie Matinee", minutes: 120 },
|
||||
{ id: -1, group: "Afternoon", name: "Filler (synthesized)", minutes: 15 },
|
||||
{ id: 301, group: "Prime", name: "Drama Block", minutes: 60 },
|
||||
{ id: 302, group: "Prime", name: "Late Night Block", minutes: 45 },
|
||||
];
|
||||
|
||||
const HISTORY = [
|
||||
{ id: 9001, when: "2026-07-17T06:00:00", finish: "2026-07-17T07:00:00", key: "2026-07-17|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
|
||||
{ id: 9002, when: "2026-07-16T06:00:00", finish: "2026-07-16T07:00:00", key: "2026-07-16|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
|
||||
{ id: 9003, when: "2026-07-15T06:00:00", finish: "2026-07-15T07:00:00", key: "2026-07-15|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
|
||||
{ id: 9004, when: "2026-07-14T06:00:00", finish: "2026-07-14T07:00:00", key: "2026-07-14|06:00|101", details: "Collection:GlobalWatch(chrono)" },
|
||||
{ id: 9005, when: "2026-07-13T06:00:00", finish: "2026-07-13T07:00:00", key: "2026-07-13|06:00|101", details: "MultiCollection:MorningMix(shuffle)" },
|
||||
{ id: 9006, when: "2026-07-12T06:00:00", finish: "2026-07-12T07:00:00", key: "2026-07-12|06:00|101", details: "Collection:GlobalWatch(shuffle)" },
|
||||
];
|
||||
|
||||
const DETAILS = {
|
||||
9001: { playbackOrder: "Shuffle", collectionType: "Collection", name: "GlobalWatch", mediaItemType: "Episode", mediaItemTitle: "Tom & Jerry — The Cat Concerto" },
|
||||
};
|
||||
|
||||
function formatDateTime(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div style={{ display: "contents" }}>
|
||||
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", borderTop: "1px solid var(--border-hairline)" }}>{label}</div>
|
||||
<div style={{ padding: "8px var(--pad-cell-x)", font: "var(--text-sm)/1.3 var(--font-mono)", color: "var(--text-primary)", borderTop: "1px solid var(--border-hairline)" }}>{value === "" ? "—" : value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionbar = { display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 };
|
||||
const section = { padding: "16px 20px 0" };
|
||||
const tableWrap = { border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" };
|
||||
const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", position: "sticky", top: 0, background: "var(--surface-card)", borderBottom: "1px solid var(--border-hairline)", zIndex: 2 };
|
||||
const td = { padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
|
||||
|
||||
function BlockPlayoutTroubleshooting() {
|
||||
const [playoutId, setPlayoutId] = React.useState("18");
|
||||
const [blockFilter, setBlockFilter] = React.useState("");
|
||||
const [selectedBlockId, setSelectedBlockId] = React.useState(102);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = React.useState(9001);
|
||||
const [pageSize, setPageSize] = React.useState("10");
|
||||
const [hoverBlock, setHoverBlock] = React.useState(null);
|
||||
const [hoverRow, setHoverRow] = React.useState(null);
|
||||
|
||||
const needle = blockFilter.trim().toLowerCase();
|
||||
const filtered = needle === "" ? BLOCKS : BLOCKS.filter((b) => b.name.toLowerCase().includes(needle));
|
||||
const groups = [];
|
||||
const gmap = {};
|
||||
filtered.forEach((b) => { if (!gmap[b.group]) { gmap[b.group] = []; groups.push(b.group); } gmap[b.group].push(b); });
|
||||
|
||||
const selectedBlock = BLOCKS.find((b) => b.id === selectedBlockId) || null;
|
||||
const details = DETAILS[selectedHistoryId] || null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
|
||||
{/* playout picker */}
|
||||
<div style={actionbar}>
|
||||
<Select value={playoutId} onChange={() => {}} options={PLAYOUTS} style={{ minWidth: 280 }} />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
<Ico n="Stethoscope" s={14} color="var(--text-faint)" /> Diagnostic view
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* block filter */}
|
||||
<div style={{ ...actionbar, borderBottom: "none", paddingBottom: 0 }}>
|
||||
<Input
|
||||
value={blockFilter}
|
||||
onChange={(e) => setBlockFilter(e.target.value)}
|
||||
placeholder="Search for blocks…"
|
||||
leadingIcon={<Ico n="Search" s={14} />}
|
||||
style={{ minWidth: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* blocks table */}
|
||||
<div style={section}>
|
||||
<div style={tableWrap}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Block</th>
|
||||
<th style={{ ...th, width: 120 }}>Minutes</th>
|
||||
<th style={{ ...th, width: 140, textAlign: "right" }}>History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g}>
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: "0 var(--pad-cell-x)", height: 30, background: "var(--ctv-bg-sunken)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-secondary)" }}>{g || "Ungrouped"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{gmap[g].map((b) => {
|
||||
const isSel = selectedBlockId === b.id;
|
||||
const isHover = hoverBlock === b.id;
|
||||
return (
|
||||
<tr
|
||||
key={b.id}
|
||||
onMouseEnter={() => setHoverBlock(b.id)}
|
||||
onMouseLeave={() => setHoverBlock(null)}
|
||||
style={{ borderTop: "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
>
|
||||
<td style={{ ...td, font: isSel ? "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)" : td.font }}>{b.name}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{b.minutes}</td>
|
||||
<td style={{ ...td, textAlign: "right" }}>
|
||||
{b.id >= 0 && (
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="History" s={14} />} onClick={() => setSelectedBlockId(b.id)}>
|
||||
History
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* history table */}
|
||||
{selectedBlock && (
|
||||
<div style={section}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 0" }}>
|
||||
<strong style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
History — {selectedBlock.name}
|
||||
</strong>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Select value={pageSize} onChange={(e) => setPageSize(e.target.value)} options={["10", "25", "50", "100"]} style={{ width: 96 }} size="sm" />
|
||||
</div>
|
||||
|
||||
<div style={tableWrap}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Start</th>
|
||||
<th style={th}>Finish</th>
|
||||
<th style={th}>Key</th>
|
||||
<th style={th}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{HISTORY.map((entry, i) => {
|
||||
const isSel = selectedHistoryId === entry.id;
|
||||
const isHover = hoverRow === entry.id;
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
onClick={() => setSelectedHistoryId(entry.id)}
|
||||
onMouseEnter={() => setHoverRow(entry.id)}
|
||||
onMouseLeave={() => setHoverRow(null)}
|
||||
style={{ cursor: "pointer", borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}
|
||||
>
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.when)}</td>
|
||||
<td style={{ ...td, whiteSpace: "nowrap" }}>{formatDateTime(entry.finish)}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.key}</td>
|
||||
<td style={{ ...td, ...mono, color: "var(--text-secondary)" }}>{entry.details}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<span><span style={mono}>{HISTORY.length}</span> entries</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<IconButton size="sm" title="Previous page" disabled><Ico n="ChevronLeft" s={15} /></IconButton>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)" }}>Page <span style={mono}>1</span> of <span style={mono}>1</span></span>
|
||||
<IconButton size="sm" title="Next page" disabled><Ico n="ChevronRight" s={15} /></IconButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* decoded history-row detail panel */}
|
||||
{selectedBlock && (
|
||||
<div style={{ ...section, paddingBottom: 20 }}>
|
||||
<Card padded title="History details" subtitle="Decoded from the selected history row">
|
||||
{details ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "180px 1fr" }}>
|
||||
<DetailRow label="Playback Order" value={details.playbackOrder} />
|
||||
<DetailRow label="Collection Type" value={details.collectionType} />
|
||||
<DetailRow label="Name" value={details.name} />
|
||||
<DetailRow label="Media Item Type" value={details.mediaItemType} />
|
||||
<DetailRow label="Media Item Title" value={details.mediaItemTitle} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "8px 0", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
Select a history row above to decode its stored key.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVBlockPlayoutTroubleshooting = BlockPlayoutTroubleshooting;
|
||||
})();
|
||||
@@ -0,0 +1,281 @@
|
||||
// Blocks screen — grouped block library (list view) drilling into a block editor
|
||||
// (metadata + ordered item table + item detail form), mirroring BlocksScreen.tsx's
|
||||
// two-mode layout (list vs. editor) inside one static mockup.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Anime Blocks",
|
||||
blocks: [
|
||||
{ id: 101, name: "Toonami Late Night", minutes: 180 },
|
||||
{ id: 102, name: "Saturday Morning Anime", minutes: 120 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "News & Talk",
|
||||
blocks: [
|
||||
{ id: 201, name: "Morning Briefing", minutes: 60 },
|
||||
{ id: 202, name: "Evening Roundup", minutes: 90 },
|
||||
{ id: 203, name: "Late Edition", minutes: 45 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Nostalgia",
|
||||
blocks: [{ id: 301, name: "90s Sitcom Hour", minutes: 60 }],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Holiday Specials",
|
||||
blocks: [],
|
||||
},
|
||||
];
|
||||
|
||||
const ITEMS = [
|
||||
{ key: "i1", collection: "Toonami Bumps & Filler", type: "Collection", order: "Shuffle", epg: true, watermarks: false },
|
||||
{ key: "i2", collection: "Cowboy Bebop", type: "Television Show", order: "Season, Episode", epg: true, watermarks: false },
|
||||
{ key: "i3", collection: "Trigun", type: "Television Show", order: "Season, Episode", epg: true, watermarks: true },
|
||||
{ key: "i4", collection: "Late Night Interstitials", type: "Smart Collection", order: "Random", epg: false, watermarks: false },
|
||||
{ key: "i5", collection: "\"toonami OR bebop\"", type: "Search Query", order: "Chronological", epg: true, watermarks: false },
|
||||
];
|
||||
|
||||
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Smart Collection", "Search Query"];
|
||||
const PLAYBACK_ORDER_OPTIONS = ["Chronological", "Shuffle", "Random", "Random Rotation"];
|
||||
const WATERMARKS = [{ id: 1, name: "Bottom-right bug" }, { id: 2, name: "Corner logo (translucent)" }];
|
||||
const GRAPHICS = [{ id: 1, name: "Now Playing lower third" }, { id: 2, name: "Up Next bumper" }];
|
||||
|
||||
function Row({ label, children, control = 360, first }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
padding: "12px var(--pad-cell-x)",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlushRow({ children, first }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 var(--pad-cell-x)",
|
||||
height: 46,
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectMock({ options, selected }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{options.map((o) => (
|
||||
<Checkbox key={o.id} checked={selected.includes(o.id)} label={o.name} onChange={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockList({ onOpenBlock }) {
|
||||
const totalBlocks = GROUPS.reduce((n, g) => n + g.blocks.length, 0);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{totalBlocks} blocks</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div style={{ flex: 1, maxWidth: 360 }}>
|
||||
<Input leadingIcon={<Ico n="Search" s={14} />} placeholder="Search for blocks…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{GROUPS.map((g) => (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New block</Button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{g.blocks.length === 0 ? (
|
||||
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>No blocks in this group.</div>
|
||||
) : (
|
||||
g.blocks.map((b, i) => (
|
||||
<FlushRow key={b.id} first={i === 0}>
|
||||
<Ico n="Boxes" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBlock}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--ctv-accent)",
|
||||
}}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
<Badge tone="neutral">{b.minutes} min</Badge>
|
||||
<Tooltip label="Copy block"><IconButton size="sm" variant="ghost" title="Copy block"><Ico n="Copy" s={14} /></IconButton></Tooltip>
|
||||
<Tooltip label="Delete block"><IconButton size="sm" variant="ghost" title="Delete block"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</FlushRow>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockEditor({ onBack }) {
|
||||
const [selectedKey, setSelectedKey] = React.useState("i2");
|
||||
const selected = ITEMS.find((it) => it.key === selectedKey) || null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All blocks</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Anime Blocks / Edit block</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Eye" s={14} />}>Preview</Button>
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save block</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card padded={false} title="Block">
|
||||
<Row control={360} first label="Block group">
|
||||
<Input disabled size="sm" value="Anime Blocks" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Block name">
|
||||
<Input size="sm" value="Toonami Late Night" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={200} label="Duration (hours)">
|
||||
<Input size="sm" type="number" trailing="hours" value="3" onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={200} label="Duration (minutes)">
|
||||
<Select value="0" onChange={() => {}} options={["0", "15", "30", "45"]} />
|
||||
</Row>
|
||||
<Row control={360} label="Stop scheduling block items">
|
||||
<Select value="Before Duration End" onChange={() => {}} options={["Before Duration End", "After Duration End"]} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Block items" actions={<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>}>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Collection</th>
|
||||
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Playback order</th>
|
||||
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Show in EPG</th>
|
||||
<th style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderBottom: "1px solid var(--border-hairline)" }}>Disable watermarks</th>
|
||||
<th style={{ textAlign: "right", padding: "0 var(--pad-cell-x)", height: 34, borderBottom: "1px solid var(--border-hairline)" }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ITEMS.map((it, index) => {
|
||||
const isSel = it.key === selectedKey;
|
||||
return (
|
||||
<tr
|
||||
key={it.key}
|
||||
onClick={() => setSelectedKey(it.key)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: isSel ? "var(--ctv-accent-soft)" : "transparent",
|
||||
borderTop: index === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span>{it.collection}</span>
|
||||
<Badge tone="neutral">{it.type}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{it.order}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={it.epg} onChange={() => {}} />
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={it.watermarks} onChange={() => {}} />
|
||||
</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 48 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", gap: 2, justifyContent: "flex-end" }}>
|
||||
<IconButton size="sm" variant="ghost" title="Duplicate"><Ico n="Copy" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move up" disabled={index === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Move down" disabled={index === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selected && (
|
||||
<Card padded={false} title="Block item">
|
||||
<Row control={360} first label="Collection type">
|
||||
<Select value={selected.type === "Television Show" ? "Television Show" : selected.type} onChange={() => {}} options={COLLECTION_TYPE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label={selected.type}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Badge tone="accent">{selected.collection}</Badge>
|
||||
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={360} label="Playback order">
|
||||
<Select value={selected.order} onChange={() => {}} options={PLAYBACK_ORDER_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Watermarks">
|
||||
<MultiSelectMock options={WATERMARKS} selected={selected.watermarks ? [1] : []} />
|
||||
</Row>
|
||||
<Row control={360} label="Graphics elements">
|
||||
<MultiSelectMock options={GRAPHICS} selected={[2]} />
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Blocks() {
|
||||
const [mode, setMode] = React.useState("list");
|
||||
return mode === "list" ? <BlockList onOpenBlock={() => setMode("editor")} /> : <BlockEditor onBack={() => setMode("list")} />;
|
||||
}
|
||||
|
||||
window.CTVBlocks = Blocks;
|
||||
})();
|
||||
@@ -10,12 +10,14 @@
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
|
||||
|
||||
// ---- Mock library (shows / movies / music / docs across libraries) --------
|
||||
// ---- Mock library (shows / movies / artists across libraries) -------------
|
||||
// `seasons: true` marks a TelevisionShow with the "browse seasons" drill-in
|
||||
// (issue #180) — lets a specific season be added instead of the whole show.
|
||||
const LIB = [
|
||||
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 },
|
||||
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96, seasons: true },
|
||||
{ id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 },
|
||||
{ id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 },
|
||||
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 },
|
||||
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84, seasons: true },
|
||||
{ id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 },
|
||||
{ id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 },
|
||||
{ id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 },
|
||||
@@ -24,27 +26,31 @@
|
||||
{ id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 },
|
||||
{ id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 },
|
||||
{ id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 },
|
||||
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 },
|
||||
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50, seasons: true },
|
||||
{ id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 },
|
||||
{ id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 },
|
||||
{ id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 },
|
||||
{ id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 },
|
||||
{ id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 },
|
||||
{ id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 },
|
||||
{ id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 },
|
||||
{ id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 },
|
||||
{ id: "u1", title: "80s Synthpop", type: "Artist", library: "Music Videos", sub: "212 items", min: 45 },
|
||||
{ id: "u2", title: "90s Alt Rock", type: "Artist", library: "Music Videos", sub: "188 items", min: 45 },
|
||||
{ id: "u3", title: "Chart Countdown", type: "Artist", library: "Music Videos", sub: "Top 40", min: 60 },
|
||||
{ id: "d1", title: "Blue Planet", type: "Movie", library: "Documentaries", sub: "1h 30m", min: 90 },
|
||||
{ id: "d2", title: "Planet Earth", type: "Movie", library: "Documentaries", sub: "50m", min: 50 },
|
||||
{ id: "d3", title: "Cosmos", type: "Movie", library: "Documentaries", sub: "1h 00m", min: 60 },
|
||||
];
|
||||
const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"];
|
||||
const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" };
|
||||
// Icon/label per LibraryBrowseMediaType — mirrors web/src/media/mediaKinds.ts TYPE_ICON/TYPE_LABEL
|
||||
// (only Movie/TelevisionShow/Artist are browsable top-level library kinds; Collection/Smart/Multi/
|
||||
// Rerun/Playlist are the "Collections" source).
|
||||
const TYPE_ICON = { Show: "Tv", Movie: "Film", Artist: "Music", Collection: "FolderTree", Multi: "Layers", Smart: "Sparkles", Rerun: "Repeat", Playlist: "ListVideo" };
|
||||
|
||||
// Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a
|
||||
// single lineup entry, so the builder isn't limited to raw library items.
|
||||
const COLLECTIONS = [
|
||||
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
|
||||
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
|
||||
{ id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true },
|
||||
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true },
|
||||
{ id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true },
|
||||
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
|
||||
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
|
||||
{ id: "c3", title: "Retro Station IDs", type: "Collection", library: "Collection", sub: "24 items", min: 12, coll: true },
|
||||
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly", min: 120, coll: true },
|
||||
{ id: "c5", title: "Halloween Marathon", type: "Collection", library: "Collection", sub: "9 films", min: 900, coll: true },
|
||||
{ id: "c6", title: "Late Night Bumpers", type: "Playlist", library: "Collection", sub: "24 items", min: 18, coll: true },
|
||||
];
|
||||
|
||||
// Channel Templates — bundle the clunky technical config; one is pre-selected.
|
||||
@@ -59,23 +65,43 @@
|
||||
sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] },
|
||||
];
|
||||
|
||||
// Advanced (~30-field) override set, grouped. Values shown as template-inherited.
|
||||
// Advanced field set — grouped + labeled to match the real ChannelBuilder's
|
||||
// ADVANCED_KEYS (web/src/builder/ChannelBuilder.tsx): 22 counted fields
|
||||
// (20 override keys + the dedicated playbackOrder/playoutMode toggles),
|
||||
// plus Group/Categories which ride along in Behavior but aren't counted.
|
||||
// kind picks the override-mode control: "select" | "input" | "switch".
|
||||
const ADV = [
|
||||
{ group: "Streaming", fields: [
|
||||
["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"],
|
||||
["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"],
|
||||
{ k: "FFmpeg profile", v: "1080p NVENC", kind: "select", opts: ["1080p NVENC", "1080p x264 veryfast", "720p x264 fast", "4K HEVC NVENC"] },
|
||||
{ k: "Streaming mode", v: "HLS Direct", kind: "select", opts: ["MPEG-TS", "HLS Direct", "HLS Segmenter", "MPEG-TS Hybrid"] },
|
||||
{ k: "Transcode mode", v: "On Demand", kind: "select", opts: ["On Demand"] },
|
||||
]},
|
||||
{ group: "Filler", fields: [
|
||||
["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"],
|
||||
["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"],
|
||||
{ k: "Fallback filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Test Pattern", "None"] },
|
||||
{ k: "Pre-roll filler", v: "Static Bumper", kind: "select", opts: ["Static Bumper", "Retro Promo", "None"] },
|
||||
{ k: "Mid-roll filler", v: "Ad Break", kind: "select", opts: ["Ad Break", "None"] },
|
||||
{ k: "Post-roll filler", v: "None", kind: "select", opts: ["Outro Card", "None"] },
|
||||
{ k: "Watermark", v: "ChicoryTV Bug", kind: "select", opts: ["ChicoryTV Bug", "None"] },
|
||||
]},
|
||||
{ group: "Playback", fields: [
|
||||
["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"],
|
||||
["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"],
|
||||
{ k: "Playback order", v: "Chronological", kind: "select", opts: ["Chronological", "Shuffle", "Random", "ShuffleInOrder", "SeasonEpisode", "Marathon"] },
|
||||
{ k: "Preferred audio language", v: "eng", kind: "input" },
|
||||
{ k: "Preferred subtitle language", v: "Not set", kind: "input" },
|
||||
{ k: "Preferred audio title", v: "Not set", kind: "input" },
|
||||
{ k: "Subtitle mode", v: "Forced", kind: "select", opts: ["None", "Forced", "Default", "Any"] },
|
||||
{ k: "Stream selector mode", v: "Default", kind: "select", opts: ["Default", "Custom", "Troubleshooting"] },
|
||||
{ k: "Stream selector", v: "Not set", kind: "input" },
|
||||
]},
|
||||
{ group: "Behavior", fields: [
|
||||
["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"],
|
||||
["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"],
|
||||
{ k: "Playout mode", v: "Always on", kind: "select", opts: ["Always on", "On demand"] },
|
||||
{ k: "Music video credits", v: "None", kind: "select", opts: ["None", "GenerateSubtitles"] },
|
||||
{ k: "Song video mode", v: "Default", kind: "select", opts: ["Default", "WithProgress"] },
|
||||
{ k: "Idle behavior", v: "Stop on disconnect", kind: "select", opts: ["Stop on disconnect", "Keep running"] },
|
||||
{ k: "Fixed start time", v: "Flexible", kind: "select", opts: ["Strict", "Flexible"] },
|
||||
{ k: "Random start point", v: "Off", kind: "switch" },
|
||||
{ k: "Shuffle schedule items", v: "Off", kind: "switch" },
|
||||
{ k: "Group", v: "ChicoryTV", kind: "input", uncounted: true },
|
||||
{ k: "Categories", v: "", kind: "input", uncounted: true, viewOk: false },
|
||||
]},
|
||||
];
|
||||
|
||||
@@ -167,7 +193,9 @@
|
||||
}
|
||||
|
||||
// ---- Library card ----------------------------------------------------------
|
||||
function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) {
|
||||
// `item.seasons` -> a TelevisionShow shows a "browse seasons" affordance
|
||||
// (issue #180) so a single season can be added instead of the whole show.
|
||||
function LibCard({ item, added, compact, onAdd, onSeasons, onDragStart, onDragEnd }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div draggable onDragStart={onDragStart} onDragEnd={onDragEnd} onDoubleClick={onAdd} title="Double-click or drag to add" className="ctv-press"
|
||||
@@ -178,6 +206,11 @@
|
||||
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.15 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
|
||||
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{item.type} · <span style={mono}>{item.sub}</span></div>
|
||||
</div>
|
||||
{item.seasons && (
|
||||
<IconButton size="sm" title="Browse seasons" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}>
|
||||
<Ico n="FolderTree" s={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="sm" title={added ? "Added" : "Add to lineup"} onClick={onAdd}>
|
||||
<Ico n={added ? "Check" : "Plus"} s={15} color={added ? "var(--action-primary)" : undefined} />
|
||||
</IconButton>
|
||||
@@ -189,6 +222,13 @@
|
||||
style={{ position: "relative", cursor: "grab" }}>
|
||||
<div style={{ position: "relative", borderRadius: "var(--radius-sm)", boxShadow: added ? "0 0 0 2px var(--action-primary)" : "none" }}>
|
||||
<Poster item={item} w="100%" h={150} />
|
||||
{item.seasons && (
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onSeasons && onSeasons(); }}
|
||||
style={{ position: "absolute", left: 6, bottom: 6, display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 7px", borderRadius: "var(--radius-pill)", cursor: "pointer",
|
||||
background: "rgba(11,13,17,.78)", border: "1px solid var(--border-control)", color: "var(--text-primary)", font: "var(--weight-medium) 10px/1 var(--font-sans)", backdropFilter: "blur(2px)" }}>
|
||||
<Ico n="FolderTree" s={11} />Seasons
|
||||
</button>
|
||||
)}
|
||||
{added && (
|
||||
<div style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--action-primary)", color: "var(--text-on-accent)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)", animation: "ctv-pop 260ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
<Ico n="Check" s={14} />
|
||||
@@ -286,9 +326,11 @@
|
||||
// ---- Advanced disclosure — one-click Override (edit) or View defaults ------
|
||||
function Advanced({ templateName, scrollerRef }) {
|
||||
const [mode, setMode] = React.useState("closed"); // closed | view | override
|
||||
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; });
|
||||
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach((f) => { o[f.k] = f.v; })); return o; });
|
||||
const override = mode === "override";
|
||||
const count = ADV.reduce((n, g) => n + g.fields.length, 0);
|
||||
// Counted fields mirror the real fieldCount = ADVANCED_KEYS.length + 2
|
||||
// (playbackOrder + playoutMode); Group/Categories ride along uncounted.
|
||||
const count = ADV.reduce((n, g) => n + g.fields.filter((f) => !f.uncounted).length, 0);
|
||||
const fieldsRef = React.useRef(null);
|
||||
const rootRef = React.useRef(null);
|
||||
const [maxH, setMaxH] = React.useState(0);
|
||||
@@ -342,16 +384,33 @@
|
||||
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
|
||||
{override ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k]) => (
|
||||
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => setVals((s) => ({ ...s, [k]: e.target.value }))} />
|
||||
))}
|
||||
{g.fields.map((f) => {
|
||||
if (f.kind === "switch") {
|
||||
const on = vals[f.k] === "On";
|
||||
return (
|
||||
<div key={f.k} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", justifyContent: "space-between", padding: "6px 2px" }}>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{f.k}</span>
|
||||
<Switch checked={on} onChange={(next) => setVals((s) => ({ ...s, [f.k]: next ? "On" : "Off" }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (f.kind === "select") {
|
||||
return (
|
||||
<Select key={f.k} size="sm" label={f.k} value={vals[f.k]} options={f.opts}
|
||||
onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input key={f.k} size="sm" label={f.k} value={vals[f.k]} onChange={(e) => setVals((s) => ({ ...s, [f.k]: e.target.value }))} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{g.fields.map(([k, v]) => (
|
||||
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
|
||||
{g.fields.filter((f) => f.kind !== "switch" && f.viewOk !== false).map((f) => (
|
||||
<div key={f.k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
|
||||
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{f.k}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(vals[f.k]) ? mono : {}) }}>{vals[f.k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -378,6 +437,7 @@
|
||||
const [tpl, setTpl] = React.useState("std");
|
||||
const [tplOpen, setTplOpen] = React.useState(false);
|
||||
const [source, setSource] = React.useState("library"); // library | collections
|
||||
const [seasonsFor, setSeasonsFor] = React.useState(null); // show whose seasons drill-in is open
|
||||
|
||||
// drag state
|
||||
const [dragLib, setDragLib] = React.useState(null); // library item id being dragged
|
||||
@@ -414,13 +474,11 @@
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* builder toolbar */}
|
||||
{/* builder toolbar — the shell's TopBar already renders the "New Channel"
|
||||
page title, so this row is just the live summary + the two actions. */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Plus" s={19} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>New Channel</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library — <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></div>
|
||||
</div>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library — <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Tooltip placement="bottom" label={canCreate ? "Creates the channel, schedule & playout" : "Name the channel and add at least one item"}>
|
||||
<Button variant="primary" disabled={!canCreate} startIcon={<Ico n="Check" s={15} />}>Create Channel</Button>
|
||||
@@ -461,6 +519,7 @@
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{filtered.map((item) => (
|
||||
<LibCard key={item.id} item={item} compact added={addedIds.has(item.id)} onAdd={() => add(item)}
|
||||
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
|
||||
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
|
||||
))}
|
||||
</div>
|
||||
@@ -468,6 +527,7 @@
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(104px,1fr))", gap: 10 }}>
|
||||
{filtered.map((item) => (
|
||||
<LibCard key={item.id} item={item} added={addedIds.has(item.id)} onAdd={() => add(item)}
|
||||
onSeasons={item.seasons ? () => setSeasonsFor(item) : undefined}
|
||||
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
|
||||
))}
|
||||
</div>
|
||||
@@ -611,6 +671,34 @@
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Seasons drill-in — expands a TelevisionShow into its seasons so one
|
||||
season can be added instead of the whole show (issue #180). */}
|
||||
{seasonsFor && (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 40, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(6,7,9,.6)" }}
|
||||
onClick={() => setSeasonsFor(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: 420, maxHeight: "70vh", display: "flex", flexDirection: "column", borderRadius: "var(--radius-lg)", background: "var(--surface-card)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-lg)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Seasons — {seasonsFor.title}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<IconButton size="sm" title="Close" onClick={() => setSeasonsFor(null)}><Ico n="X" s={15} /></IconButton>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 10, display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{["Season 1", "Season 2", "Season 3", "Specials"].map((s) => (
|
||||
<div key={s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "6px 8px", borderRadius: "var(--radius-sm)" }}>
|
||||
<Poster item={{ title: s, type: seasonsFor.type }} w={40} h={54} mini />
|
||||
<div style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{s}</div>
|
||||
<IconButton size="sm" title="Add to lineup"><Ico n="Plus" s={15} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
|
||||
<Button variant="secondary" onClick={() => setSeasonsFor(null)}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
// Channel Edit screen — left sub-nav rail (General / Playout / Streaming / Stream
|
||||
// selection / Music video / Branding) + one focused form pane per section, dense
|
||||
// label+help / control rows inside a flush Card (mirrors Settings.jsx). A floating
|
||||
// save bar surfaces validation state and appears only while the draft is dirty.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Card, Button, Badge, ChannelLogo, Input, Select, Switch } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "general", label: "General", icon: "Tv", hint: "Number, name, group" },
|
||||
{ id: "playout", label: "Playout", icon: "ListVideo", hint: "Source & scheduling" },
|
||||
{ id: "streaming", label: "Streaming", icon: "SlidersHorizontal", hint: "FFmpeg & output" },
|
||||
{ id: "selection", label: "Stream selection", icon: "AudioLines", hint: "Audio & subtitles" },
|
||||
{ id: "music", label: "Music video", icon: "Music", hint: "Credits & songs" },
|
||||
{ id: "branding", label: "Branding", icon: "Image", hint: "Logo & watermark" },
|
||||
];
|
||||
|
||||
const FFMPEG_PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "Copy (no transcode)"];
|
||||
const WATERMARKS = ["(none)", "Station bug — corner", "Station bug — large"];
|
||||
const FILLER_PRESETS = ["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"];
|
||||
const LANGUAGES = ["(none)", "English", "Japanese", "Spanish", "French", "German"];
|
||||
const CREDITS_TEMPLATES = ["(none)", "Classic Credits", "Minimal Credits"];
|
||||
const STREAM_SELECTORS = ["(none)", "prefer-commentary.py", "anime-dual-audio.py"];
|
||||
const MIRROR_CHANNELS = ["(none)", "(1.1) - News 24", "(3.1) - Movie Vault", "(9.1) - Kids Classics"];
|
||||
const SLUG_OPTIONS = ["(none)", "0.5 seconds", "1 second", "2 seconds", "3 seconds", "5 seconds"];
|
||||
|
||||
const SAVED = {
|
||||
number: "5.1", name: "Retro Cartoons", group: "Entertainment", categories: "Animation, Kids",
|
||||
isEnabled: true, showInEpg: true,
|
||||
playoutSource: "Generated", playoutMode: "Continuous", mirrorSourceChannel: "(none)", playoutOffsetHours: "0",
|
||||
idleBehavior: "Stop On Disconnect",
|
||||
streamingMode: "MPEG-TS", ffmpegProfile: "1080p H.264", slugSeconds: "1 second",
|
||||
streamSelectorMode: "Default", preferredAudioLanguage: "English", preferredAudioTitle: "",
|
||||
preferredSubtitleLanguage: "(none)", subtitleMode: "None", streamSelector: "(none)",
|
||||
musicVideoCreditsMode: "None", musicVideoCreditsTemplate: "(none)", songVideoMode: "Default",
|
||||
externalLogoUrl: "", watermark: "(none)", fallbackFiller: "Retro Bumpers",
|
||||
};
|
||||
const PLAYOUT_LOCKED = true; // this channel already has a generated playout
|
||||
|
||||
/* ---------- shared row primitives (mirror Settings.jsx / ChannelEditScreen) ---------- */
|
||||
|
||||
function Pane({ title, subtitle, children }) {
|
||||
return (
|
||||
<div style={{ maxWidth: 720, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
<div>
|
||||
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
|
||||
<div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, help, children, first, control = 300 }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
|
||||
function GeneralPane({ v, set }) {
|
||||
return (
|
||||
<Pane title="General" subtitle="Identity shown in the lineup, guide and IPTV playlist.">
|
||||
<Card padded={false}>
|
||||
<Row first control={200} label="Number" help="Displayed channel number, e.g. 5 or 5.1.">
|
||||
<Input size="sm" fullWidth value={v.number} onChange={(e) => set("number", e.target.value)}
|
||||
error={v.number.trim() ? null : "Number is required"} />
|
||||
</Row>
|
||||
<Row label="Name" help="Channel display name.">
|
||||
<Input size="sm" fullWidth value={v.name} onChange={(e) => set("name", e.target.value)}
|
||||
error={v.name.trim() ? null : "Name is required"} />
|
||||
</Row>
|
||||
<Row label="Group" help="Groups channels in clients that support it.">
|
||||
<Input size="sm" fullWidth value={v.group} onChange={(e) => set("group", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Categories" help="Comma-separated list of categories.">
|
||||
<Input size="sm" fullWidth placeholder="News, Sports" value={v.categories} onChange={(e) => set("categories", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Enabled" help="Whether this channel is served to clients.">
|
||||
<Switch size="sm" checked={v.isEnabled} onChange={(next) => set(next ? "isEnabled" : ["isEnabled", "showInEpg"], next)} />
|
||||
</Row>
|
||||
<Row label="Show in EPG" help="Include this channel in the XMLTV guide.">
|
||||
<Switch size="sm" checked={v.showInEpg} disabled={!v.isEnabled} onChange={(next) => set("showInEpg", next)} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutPane({ v, set }) {
|
||||
const isMirror = v.playoutSource === "Mirror";
|
||||
return (
|
||||
<Pane title="Playout" subtitle="Where the channel's content comes from and how it progresses.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Playout source"
|
||||
help={PLAYOUT_LOCKED ? "Cannot be changed once a generated channel has a playout." : "Generated builds its own schedule; Mirror follows another channel."}>
|
||||
<Select size="sm" fullWidth disabled={PLAYOUT_LOCKED} value={v.playoutSource}
|
||||
onChange={(e) => set("playoutSource", e.target.value)} options={["Generated", "Mirror"]} />
|
||||
</Row>
|
||||
{isMirror ? (
|
||||
<React.Fragment>
|
||||
<Row label="Mirror source channel" help="The generated channel this channel mirrors.">
|
||||
<Select size="sm" fullWidth value={v.mirrorSourceChannel} onChange={(e) => set("mirrorSourceChannel", e.target.value)} options={MIRROR_CHANNELS} />
|
||||
</Row>
|
||||
<Row control={160} label="Playout offset" help="Shift the mirrored playout by this many hours.">
|
||||
<Input size="sm" fullWidth type="number" style={mono} value={v.playoutOffsetHours}
|
||||
onChange={(e) => set("playoutOffsetHours", e.target.value)}
|
||||
trailing={<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>hours</span>} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Playout mode" help="Controls how the generated playout progresses.">
|
||||
<Select size="sm" fullWidth value={v.playoutMode} onChange={(e) => set("playoutMode", e.target.value)} options={["Continuous", "On Demand"]} />
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Idle behavior" help="How the transcoder behaves once all clients disconnect.">
|
||||
<Select size="sm" fullWidth value={v.idleBehavior} onChange={(e) => set("idleBehavior", e.target.value)} options={["Stop On Disconnect", "Keep Running"]} />
|
||||
</Row>
|
||||
<Row label="Transcode mode" help="When the transcoding process is active. Only On Demand is supported.">
|
||||
<Select size="sm" fullWidth disabled value="On Demand" options={["On Demand"]} onChange={() => {}} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingPane({ v, set }) {
|
||||
const hlsDirect = v.streamingMode === "HLS Direct";
|
||||
return (
|
||||
<Pane title="Streaming" subtitle="Output container and the transcoding profile applied to this channel.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Streaming mode" help="Delivery container / protocol served to clients.">
|
||||
<Select size="sm" fullWidth value={v.streamingMode} onChange={(e) => set("streamingMode", e.target.value)}
|
||||
options={["MPEG-TS", "MPEG-TS (Legacy)", "HLS Direct", "HLS Segmenter"]} />
|
||||
</Row>
|
||||
<Row label="FFmpeg profile" help={hlsDirect ? "Not used in HLS Direct mode." : "Transcoding preset for this channel."}>
|
||||
<Select size="sm" fullWidth disabled={hlsDirect} value={v.ffmpegProfile} onChange={(e) => set("ffmpegProfile", e.target.value)} options={FFMPEG_PROFILES} />
|
||||
</Row>
|
||||
<Row label="Slug seconds" help="Black video / silent audio inserted between every playout item.">
|
||||
<Select size="sm" fullWidth value={v.slugSeconds} onChange={(e) => set("slugSeconds", e.target.value)} options={SLUG_OPTIONS} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionPane({ v, set }) {
|
||||
const isDefault = v.streamSelectorMode === "Default";
|
||||
return (
|
||||
<Pane title="Stream selection" subtitle="How audio and subtitle tracks are chosen for playback.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Stream selector mode" help="Default picks tracks by preference; Custom uses a selector script.">
|
||||
<Select size="sm" fullWidth value={v.streamSelectorMode} onChange={(e) => set("streamSelectorMode", e.target.value)} options={["Default", "Custom"]} />
|
||||
</Row>
|
||||
{isDefault ? (
|
||||
<React.Fragment>
|
||||
<Row label="Preferred audio language" help="Blank keeps the file order.">
|
||||
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)} options={LANGUAGES} />
|
||||
</Row>
|
||||
<Row label="Preferred audio title" help="Prefer an audio track whose title contains this text.">
|
||||
<Input size="sm" fullWidth value={v.preferredAudioTitle} onChange={(e) => set("preferredAudioTitle", e.target.value)} />
|
||||
</Row>
|
||||
<Row label="Preferred subtitle language" help="Blank disables preference.">
|
||||
<Select size="sm" fullWidth value={v.preferredSubtitleLanguage} onChange={(e) => set("preferredSubtitleLanguage", e.target.value)} options={LANGUAGES} />
|
||||
</Row>
|
||||
<Row label="Subtitle mode" help="When subtitle tracks are burned in.">
|
||||
<Select size="sm" fullWidth value={v.subtitleMode} onChange={(e) => set("subtitleMode", e.target.value)} options={["None", "Forced", "Default", "Any"]} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Stream selector" help="Name of a custom stream-selector script in the config folder.">
|
||||
<Select size="sm" fullWidth value={v.streamSelector} onChange={(e) => set("streamSelector", e.target.value)} options={STREAM_SELECTORS} />
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function MusicPane({ v, set }) {
|
||||
const creditsOn = v.musicVideoCreditsMode === "Generate Subtitles";
|
||||
return (
|
||||
<Pane title="Music video" subtitle="Overlays and progress behavior for music video and song content.">
|
||||
<Card padded={false}>
|
||||
<Row first label="Credits mode" help="Generate on-screen credits for music videos.">
|
||||
<Select size="sm" fullWidth value={v.musicVideoCreditsMode} onChange={(e) => set("musicVideoCreditsMode", e.target.value)} options={["None", "Generate Subtitles"]} />
|
||||
</Row>
|
||||
<Row label="Credits template" help="Name of the credits template. Only used when credits are generated.">
|
||||
<Select size="sm" fullWidth disabled={!creditsOn} value={v.musicVideoCreditsTemplate} onChange={(e) => set("musicVideoCreditsTemplate", e.target.value)} options={CREDITS_TEMPLATES} />
|
||||
</Row>
|
||||
<Row label="Song video mode" help="Optional progress bar overlay for song content.">
|
||||
<Select size="sm" fullWidth value={v.songVideoMode} onChange={(e) => set("songVideoMode", e.target.value)} options={["Default", "With Progress"]} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingPane({ v, set }) {
|
||||
const hlsDirect = v.streamingMode === "HLS Direct";
|
||||
const urlValid = !v.externalLogoUrl.trim() || /^https?:\/\//i.test(v.externalLogoUrl.trim());
|
||||
const previewSrc = v.externalLogoUrl.trim() || undefined;
|
||||
return (
|
||||
<Pane title="Branding" subtitle="Channel logo and the overlays applied while streaming.">
|
||||
<Card padded={false}>
|
||||
<Row first control={340} label="Logo" help="Shown in the guide and as the on-screen bug.">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, width: "100%" }}>
|
||||
<ChannelLogo name={v.name} size={48} src={previewSrc} />
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Upload" s={14} />}>Upload logo</Button>
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={340} label="External logo URL" help="An external image URL wins over an uploaded logo when both are set.">
|
||||
<Input size="sm" fullWidth placeholder="https://example.com/logo.png" value={v.externalLogoUrl}
|
||||
onChange={(e) => set("externalLogoUrl", e.target.value)}
|
||||
error={urlValid ? null : "Enter a valid http(s) URL"} />
|
||||
</Row>
|
||||
<Row label="Watermark" help={hlsDirect ? "Not used in HLS Direct mode." : "Overlay applied to the channel."}>
|
||||
<Select size="sm" fullWidth disabled={hlsDirect} value={v.watermark} onChange={(e) => set("watermark", e.target.value)} options={WATERMARKS} />
|
||||
</Row>
|
||||
<Row label="Fallback filler" help="Plays when the playout has nothing scheduled.">
|
||||
<Select size="sm" fullWidth value={v.fallbackFiller} onChange={(e) => set("fallbackFiller", e.target.value)} options={FILLER_PRESETS} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
const PANES = {
|
||||
general: GeneralPane, playout: PlayoutPane, streaming: StreamingPane,
|
||||
selection: SelectionPane, music: MusicPane, branding: BrandingPane,
|
||||
};
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
function ChannelEdit() {
|
||||
const [section, setSection] = React.useState("general");
|
||||
const [saved, setSaved] = React.useState(SAVED);
|
||||
const [draft, setDraft] = React.useState(SAVED);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [justSaved, setJustSaved] = React.useState(false);
|
||||
|
||||
const set = (key, val) => {
|
||||
setJustSaved(false);
|
||||
if (Array.isArray(key)) {
|
||||
setDraft((d) => ({ ...d, [key[0]]: val, [key[1]]: val ? d[key[1]] : false }));
|
||||
} else {
|
||||
setDraft((d) => ({ ...d, [key]: val }));
|
||||
}
|
||||
};
|
||||
|
||||
const nameValid = Boolean(draft.name.trim());
|
||||
const numberValid = Boolean(draft.number.trim());
|
||||
const urlValid = !draft.externalLogoUrl.trim() || /^https?:\/\//i.test(draft.externalLogoUrl.trim());
|
||||
const valid = nameValid && numberValid && urlValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
|
||||
const PaneCmp = PANES[section];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
|
||||
{/* sub-nav rail */}
|
||||
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
|
||||
{SECTIONS.map((s) => {
|
||||
const active = s.id === section;
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
|
||||
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
|
||||
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
|
||||
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
|
||||
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
|
||||
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* pane */}
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
|
||||
<PaneCmp key={section} v={draft} set={set} />
|
||||
</div>
|
||||
|
||||
{/* floating save bar */}
|
||||
{(dirty || justSaved) && (
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
|
||||
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
|
||||
{justSaved ? (
|
||||
<React.Fragment>
|
||||
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel saved</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Unsaved changes</span>
|
||||
{(!nameValid || !numberValid) && <Badge tone="neutral">Name and number are required</Badge>}
|
||||
{!urlValid && <Badge tone="neutral">Logo URL must be http(s)</Badge>}
|
||||
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
|
||||
<Button size="sm" variant="primary" loading={saving} disabled={!valid || saving} startIcon={<Ico n="Check" s={14} />}
|
||||
onClick={() => {
|
||||
setSaving(true);
|
||||
window.setTimeout(() => {
|
||||
setSaving(false);
|
||||
setSaved(draft);
|
||||
setJustSaved(true);
|
||||
window.setTimeout(() => setJustSaved(false), 1800);
|
||||
}, 500);
|
||||
}}>
|
||||
Save changes
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVChannelEdit = ChannelEdit;
|
||||
})();
|
||||
@@ -0,0 +1,183 @@
|
||||
// Collections screen — manual vs. smart collection management, a flush
|
||||
// browse list per tab, and a drill-in "manage items" detail pane for a
|
||||
// manual collection (list + reorder-ready detail, mirroring the live SPA).
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Switch } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const MANUAL = [
|
||||
{ id: 1, name: "Halloween Marathon", custom: true, count: 18 },
|
||||
{ id: 2, name: "Saturday Morning Cartoons", custom: true, count: 42 },
|
||||
{ id: 3, name: "Best of Kurosawa", custom: false, count: 11 },
|
||||
{ id: 4, name: "90s Music Videos", custom: false, count: 63 },
|
||||
{ id: 5, name: "Rainy Day Comfort Movies", custom: true, count: 9 },
|
||||
];
|
||||
|
||||
const SMART = [
|
||||
{ id: 1, name: "Recent Action Movies", query: 'genre:"action" AND released:2015-2026', count: 27 },
|
||||
{ id: 2, name: "80s Synthpop", query: 'genre:"synthpop" AND released:1980-1989', count: 14 },
|
||||
{ id: 3, name: "Unwatched Documentaries", query: 'genre:"documentary" AND played:false', count: 38 },
|
||||
];
|
||||
|
||||
const ITEMS = [
|
||||
{ id: 1, title: "The Cat Concerto", type: "Episode" },
|
||||
{ id: 2, title: "Puss Gets the Boot", type: "Episode" },
|
||||
{ id: 3, title: "Jerry and the Lion", type: "Episode" },
|
||||
{ id: 4, title: "The Night Before Halloween", type: "Movie" },
|
||||
{ id: 5, title: "Trick or Treat House", type: "Movie" },
|
||||
{ id: 6, title: "Season 3", type: "Season" },
|
||||
{ id: 7, title: "Hocus Pocus", type: "Movie" },
|
||||
{ id: 8, title: "The Great Pumpkin", type: "Episode" },
|
||||
];
|
||||
|
||||
const rowStyle = (first) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 var(--pad-cell-x)",
|
||||
height: 46,
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
});
|
||||
|
||||
const flushMain = { flex: 1, minWidth: 0, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
|
||||
|
||||
function Segmented({ view, setView, counts }) {
|
||||
return (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "manual", l: "Manual", c: counts.manual }, { v: "smart", l: "Smart", c: counts.smart }].map((o) => {
|
||||
const on = o.v === view;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setView(o.v)}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{o.c}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualItemsPane({ collection, onBack }) {
|
||||
const [reordering, setReordering] = React.useState(false);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>All collections</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{collection.name}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{collection.custom && !reordering && (
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="ArrowUp" s={14} />} onClick={() => setReordering(true)}>Reorder</Button>
|
||||
)}
|
||||
<Button size="sm" variant="primary" startIcon={<Ico n="Plus" s={14} />} disabled={reordering}>Add items</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{ITEMS.map((item, i) => (
|
||||
<div key={item.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="ListVideo" s={15} color="var(--text-disabled)" />
|
||||
<span style={flushMain}>{item.title}</span>
|
||||
<Badge tone="neutral">{item.type}</Badge>
|
||||
{reordering ? (
|
||||
<React.Fragment>
|
||||
<IconButton size="sm" variant="ghost" title={`Move ${item.title} up`} disabled={i === 0}><Ico n="ArrowUp" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title={`Move ${item.title} down`} disabled={i === ITEMS.length - 1}><Ico n="ArrowDown" s={14} /></IconButton>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<IconButton size="sm" variant="ghost" title={`Remove ${item.title}`}><Ico n="Trash2" s={14} /></IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 20px", borderTop: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
{reordering ? (
|
||||
<React.Fragment>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}><span style={mono}>{ITEMS.length}</span> items</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setReordering(false)}>Cancel</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => setReordering(false)}>Save order</Button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Showing <span style={mono}>{ITEMS.length}</span> of <span style={mono}>{collection.count}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Collections() {
|
||||
const [view, setView] = React.useState("manual");
|
||||
const [selected, setSelected] = React.useState(null);
|
||||
|
||||
if (selected) {
|
||||
return <ManualItemsPane collection={selected} onBack={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Segmented view={view} setView={setView} counts={{ manual: MANUAL.length, smart: SMART.length }} />
|
||||
<span style={{ flex: 1 }} />
|
||||
{view === "manual" ? (
|
||||
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New collection</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" startIcon={<Ico n="Plus" s={14} />}>New smart collection</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{view === "manual" ? (
|
||||
<Card padded={false}>
|
||||
{MANUAL.map((c, i) => (
|
||||
<div key={c.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="FolderTree" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" onClick={() => setSelected(c)}
|
||||
style={{ ...flushMain, textAlign: "left", background: "none", border: "none", padding: 0, cursor: "pointer", font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{c.name}
|
||||
</button>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} items</span>
|
||||
<label style={{ display: "inline-flex", alignItems: "center", gap: 7, cursor: "pointer" }} title="Use custom playback order">
|
||||
<Switch checked={c.custom} size="sm" />
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Custom order</span>
|
||||
</label>
|
||||
<IconButton size="sm" variant="ghost" title="Manage items" onClick={() => setSelected(c)}><Ico n="ListVideo" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Rename"><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : (
|
||||
<Card padded={false}>
|
||||
{SMART.map((c, i) => (
|
||||
<div key={c.id} style={rowStyle(i === 0)}>
|
||||
<Ico n="Sparkles" s={15} color="var(--ctv-accent)" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</div>
|
||||
<div style={{ ...mono, marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-mono)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.query}</div>
|
||||
</div>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{c.count} matches</span>
|
||||
<IconButton size="sm" variant="ghost" title="Edit"><Ico n="Pencil" s={14} /></IconButton>
|
||||
<IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 10, marginTop: 16, padding: "12px 14px", borderRadius: "var(--radius-md)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="Info" s={14} color="var(--text-disabled)" />
|
||||
<span style={{ font: "var(--text-xs)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Multi-collections, rerun collections and playlists aren't available here yet — manage them in the Classic UI for now.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVCollections = Collections;
|
||||
})();
|
||||
@@ -1,145 +1,194 @@
|
||||
// Dashboard — spec 4.1: On-air-now cards, stat tiles, Health panel,
|
||||
// Recent activity, collapsible Release notes.
|
||||
// Dashboard screen — at-a-glance stat row, on-air-now card grid, and a
|
||||
// system-health panel. Mirrors DashboardScreen.tsx: a success state (stat
|
||||
// row + two-column grid) plus the loading/error states the live screen
|
||||
// falls back to while the API request is in flight or fails.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, IconButton } = NS;
|
||||
const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, Button, Spinner } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
|
||||
const toneColor = { accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
// health status -> icon + color
|
||||
const HEALTH = {
|
||||
pass: { icon: "Check", color: "var(--status-ok)", label: "Pass" },
|
||||
info: { icon: "Info", color: "var(--ctv-accent)", label: "Info" },
|
||||
warning: { icon: "TriangleAlert", color: "var(--status-warn)", label: "Warning" },
|
||||
fail: { icon: "CircleX", color: "var(--status-error)", label: "Fail" },
|
||||
};
|
||||
const STATS = [
|
||||
{ label: "Channels", value: "24", icon: <Ico n="Tv" s={15} /> },
|
||||
{ label: "Active playouts", value: "22", icon: <Ico n="ListVideo" s={15} /> },
|
||||
{ label: "On air", value: "4", icon: <Ico n="Radio" s={15} /> },
|
||||
{ label: "Libraries", value: "7", icon: <Ico n="Library" s={15} /> },
|
||||
];
|
||||
|
||||
function OnAirCard({ ch, program, pct, remain }) {
|
||||
const ON_AIR = [
|
||||
{ num: "1.1", name: "Toon Classics", title: "Tom & Jerry — The Cat Concerto", pct: 48, remain: 14 },
|
||||
{ num: "2.1", name: "News 24", title: "World Tonight — Evening bulletin", pct: 34, remain: 27 },
|
||||
{ num: "4.2", name: "Retro Radio", title: "80s Block — Synthpop hour", pct: 51, remain: 19 },
|
||||
{ num: "9.1", name: "Kids Corner", title: "Sesame Street — Episode 4102", pct: 82, remain: 6 },
|
||||
];
|
||||
|
||||
// status mirrors ErsatzTV.Application/Health/Mapper.cs: pass | fail | warn | info
|
||||
const HEALTH = [
|
||||
{ title: "FFmpeg", detail: "7.1.1 detected on PATH", status: "pass" },
|
||||
{ title: "Media sources", detail: "5 of 5 libraries reachable", status: "pass" },
|
||||
{ title: "Transcode folder", detail: "/config/transcode is writable", status: "pass" },
|
||||
{ title: "HDHomeRun tuners", detail: "2 tuners idle, 2 in use", status: "pass" },
|
||||
{ title: "Disk space", detail: "/config at 88% capacity", status: "warn" },
|
||||
{ title: "Plex integration", detail: "Not configured", status: "info" },
|
||||
];
|
||||
|
||||
function healthIconStatus(status) {
|
||||
if (status === "fail") return "error";
|
||||
if (status === "warn") return "warn";
|
||||
if (status === "info") return "idle";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function healthIconName(status) {
|
||||
const s = healthIconStatus(status);
|
||||
if (s === "error" || s === "warn") return "TriangleAlert";
|
||||
if (s === "idle") return "Info";
|
||||
return "Check";
|
||||
}
|
||||
|
||||
function summarizeHealth(checks) {
|
||||
const failed = checks.filter((c) => c.status === "fail").length;
|
||||
const warned = checks.filter((c) => c.status === "warn").length;
|
||||
if (failed > 0) return { label: `${failed} failing`, status: "error" };
|
||||
if (warned > 0) return { label: `${warned} warning${warned === 1 ? "" : "s"}`, status: "warn" };
|
||||
return { label: "Healthy", status: "ok" };
|
||||
}
|
||||
|
||||
function OnAirCard({ ch }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: 12, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ display: "grid", gap: 10, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)", padding: 12 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0,1fr) auto", alignItems: "center", gap: 8 }}>
|
||||
<ChannelLogo name={ch.name} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{ch.num}</span>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
|
||||
</div>
|
||||
<div style={{ minWidth: 0, display: "grid", gap: 2 }}>
|
||||
<code style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>{ch.num}</code>
|
||||
<strong style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</strong>
|
||||
</div>
|
||||
<StatusDot status="live" />
|
||||
<Badge tone="accent" dot>On air</Badge>
|
||||
</div>
|
||||
<div style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{program}</div>
|
||||
<div>
|
||||
<ProgressBar value={pct} tone="accent" height={4} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
|
||||
<span>{pct}%</span>
|
||||
<span>{remain} to next</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.title}</p>
|
||||
<ProgressBar value={ch.pct} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>
|
||||
<span>{ch.pct}% elapsed</span>
|
||||
<span>{ch.remain}m to next</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseNotes() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const rn = D.releaseNotes;
|
||||
function HealthPanel() {
|
||||
const summary = summarizeHealth(HEALTH);
|
||||
return (
|
||||
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
|
||||
<button type="button" onClick={() => setOpen((o) => !o)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "13px 16px", border: "none", background: "transparent", cursor: "pointer", textAlign: "left" }}>
|
||||
<Ico n="Sparkles" s={15} color="var(--text-secondary)" />
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>What's new</span>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{rn.version} · {rn.date}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={16} color="var(--text-disabled)" />
|
||||
</button>
|
||||
{open && (
|
||||
<ul style={{ margin: 0, padding: "4px 18px 16px 34px", display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
{rn.items.map((it, i) => (
|
||||
<li key={i} style={{ font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{it}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>System health</h2>}
|
||||
subtitle="Backend health checks"
|
||||
actions={<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={14} />}>Refresh health</Button>}
|
||||
padded={false}
|
||||
>
|
||||
<div>
|
||||
{HEALTH.map((h, i) => {
|
||||
const rowStatus = healthIconStatus(h.status);
|
||||
const colorVar = { ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)", idle: "var(--text-secondary)" }[rowStatus];
|
||||
return (
|
||||
<div key={h.title} style={{ display: "grid", gridTemplateColumns: "auto auto minmax(0,1fr) auto", alignItems: "center", gap: 8, minHeight: 46, padding: "0 12px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ width: 24, height: 24, display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-xs)", background: "var(--surface-selected)", color: colorVar }}>
|
||||
<Ico n={healthIconName(h.status)} s={15} />
|
||||
</span>
|
||||
<strong style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap" }}>{h.title}</strong>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
|
||||
<StatusDot status={rowStatus} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", borderTop: "1px solid var(--border-hairline)", padding: "8px 12px" }}>
|
||||
<StatusDot status={summary.status} label={summary.label} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLoadingState() {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ minHeight: 220, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, color: "var(--text-secondary)" }}>
|
||||
<Spinner size={20} tone="accent" />
|
||||
<span style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>Loading dashboard</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardErrorState() {
|
||||
return (
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Dashboard unavailable</h2>}
|
||||
subtitle="Live API request failed"
|
||||
>
|
||||
<div style={{ display: "grid", gap: 6, border: "1px solid var(--status-error)", borderRadius: "var(--radius-md)", background: "var(--ctv-error-soft)", padding: 12 }}>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--status-error)" }}>API request failed</span>
|
||||
<strong style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>GET /api/dashboard returned 504 Gateway Timeout</strong>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const onAir = D.channels.filter((c) => c.live).slice(0, 4);
|
||||
const progs = [
|
||||
{ program: "Now: Looney Tunes — Rabbit Fire", pct: 68, remain: "07:41" },
|
||||
{ program: "Now: World Tonight — Evening bulletin", pct: 34, remain: "19:22" },
|
||||
{ program: "Now: 80s Block — Synthpop hour", pct: 51, remain: "14:08" },
|
||||
{ program: "Now: Sesame Street — Episode 4102", pct: 82, remain: "05:12" },
|
||||
];
|
||||
const failCount = D.health.filter((h) => h.status === "fail").length;
|
||||
const warnCount = D.health.filter((h) => h.status === "warning").length;
|
||||
const [state, setState] = React.useState("success");
|
||||
|
||||
const StateSwitcher = () => (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "success", l: "Live data" }, { v: "loading", l: "Loading" }, { v: "error", l: "Error" }].map((o) => {
|
||||
const on = o.v === state;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setState(o.v)}
|
||||
style={{ display: "inline-flex", alignItems: "center", height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* stat tiles */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14 }}>
|
||||
<Card><Stat label="Channels" value="128" icon={<Ico n="Tv" s={15} />} /></Card>
|
||||
<Card><Stat label="Active playouts" value="112" icon={<Ico n="ListVideo" s={15} />} /></Card>
|
||||
<Card><Stat label="Transcodes" value="4" icon={<Ico n="Radio" s={15} />} delta="live" deltaTone="neutral" /></Card>
|
||||
<Card><Stat label="Libraries" value="5" icon={<Ico n="Library" s={15} />} /></Card>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<StateSwitcher />
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
<StatusDot status="live" size={7} /><span style={mono}>4</span> on air
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
{/* On air now */}
|
||||
<Card title="On air now" subtitle={`${onAir.length} channels streaming`} actions={<Badge tone="accent" dot>Live</Badge>}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
{onAir.map((c, i) => <OnAirCard key={c.num} ch={c} {...progs[i]} />)}
|
||||
</div>
|
||||
</Card>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
{state === "loading" && <DashboardLoadingState />}
|
||||
{state === "error" && <DashboardErrorState />}
|
||||
{state === "success" && (
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<section aria-label="At a glance" style={{ display: "grid", gridTemplateColumns: "repeat(4,minmax(0,1fr))", gap: 16 }}>
|
||||
{STATS.map((s) => (
|
||||
<Card key={s.label}><Stat label={s.label} value={s.value} icon={s.icon} /></Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Health panel */}
|
||||
<Card title="System health" padded={false}
|
||||
actions={<span style={{ display: "inline-flex", gap: 6 }}>
|
||||
{failCount > 0 && <Badge tone="error" dot>{failCount} fail</Badge>}
|
||||
{warnCount > 0 && <Badge tone="warn" dot>{warnCount} warn</Badge>}
|
||||
</span>}>
|
||||
<div>
|
||||
{D.health.map((h, i) => {
|
||||
const s = HEALTH[h.status];
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", cursor: "pointer" }}>
|
||||
<Ico n={s.icon} s={15} color={s.color} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", flex: "0 0 auto" }}>{h.title}</span>
|
||||
<span style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-disabled)", flex: 1, textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
|
||||
<Ico n="ChevronRight" s={14} color="var(--text-faint)" />
|
||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(0,2fr) minmax(260px,1fr)", gap: 16, alignItems: "start" }}>
|
||||
<Card
|
||||
title={<h2 style={{ margin: 0, font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>On air now</h2>}
|
||||
subtitle="Current programmes by channel"
|
||||
actions={<Badge tone="accent" dot>{ON_AIR.length} on air</Badge>}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,minmax(0,1fr))", gap: 10 }}>
|
||||
{ON_AIR.map((ch) => <OnAirCard key={ch.num} ch={ch} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
{/* Recent activity */}
|
||||
<Card title="Recent activity" padded={false}>
|
||||
<div>
|
||||
{D.activity.map((a, i) => (
|
||||
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, flex: "0 0 auto", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: toneColor[a.tone] }}>
|
||||
<Ico n={a.icon} s={13} />
|
||||
</span>
|
||||
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{a.text}</span>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)", whiteSpace: "nowrap", marginTop: 2 }}>{a.time}</span>
|
||||
</div>
|
||||
))}
|
||||
<HealthPanel />
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Release notes (collapsible, secondary) */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<ReleaseNotes />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDashboard = Dashboard;
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Deco Templates screen — group'd list of named time-of-day schedules ("decos" bound to windows
|
||||
// across a 24h clock), with a group/name editor + add-content row + ordered items table for the
|
||||
// detail view. Mirrors DecoTemplatesScreen.tsx's list (Card-per-group, flush rows) and editor
|
||||
// (group/name card, add-content card, items table) states; a small local toggle shows both here.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Default",
|
||||
templates: [
|
||||
{ id: 101, name: "Standard weekday" },
|
||||
{ id: 102, name: "Standard weekend" },
|
||||
{ id: 103, name: "Late night bumper-only" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Holiday",
|
||||
templates: [
|
||||
{ id: 104, name: "Halloween week" },
|
||||
{ id: 105, name: "Christmas Eve — Christmas Day" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Overnight",
|
||||
templates: [{ id: 106, name: "Infomercial block 00:00-06:00" }],
|
||||
},
|
||||
{ id: 4, name: "Seasonal (unused)", templates: [] },
|
||||
];
|
||||
|
||||
const DECO_GROUP_OPTIONS = [
|
||||
{ label: "Select a deco group…", value: "" },
|
||||
{ label: "Bumpers", value: "1" },
|
||||
{ label: "Lower thirds", value: "2" },
|
||||
{ label: "Watermarks", value: "3" },
|
||||
];
|
||||
const DECO_OPTIONS = [
|
||||
{ label: "Select a deco…", value: "" },
|
||||
{ label: "Station ID — short", value: "11" },
|
||||
{ label: "Up next bumper", value: "12" },
|
||||
{ label: "Weather watermark", value: "13" },
|
||||
];
|
||||
const START_TIME_OPTIONS = [
|
||||
{ label: "00:00", value: "00:00:00" },
|
||||
{ label: "06:00", value: "06:00:00" },
|
||||
{ label: "09:00", value: "09:00:00" },
|
||||
{ label: "18:00", value: "18:00:00" },
|
||||
{ label: "22:00", value: "22:00:00" },
|
||||
];
|
||||
const DURATION_MINUTE_OPTIONS = [0, 5, 15, 30, 45].map((m) => ({ label: String(m), value: String(m) }));
|
||||
|
||||
const DRAFT_ITEMS = [
|
||||
{ key: "i1", time: "00:00-06:00", deco: "Infomercial block deco" },
|
||||
{ key: "i2", time: "06:00-09:00", deco: "Morning watermark" },
|
||||
{ key: "i3", time: "09:00-18:00", deco: "Daytime lower third" },
|
||||
{ key: "i4", time: "18:00-22:00", deco: "Prime time bumper set" },
|
||||
{ key: "i5", time: "22:00-24:00", deco: "Late night watermark" },
|
||||
];
|
||||
|
||||
const flushRow = { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 16px", height: 46, borderTop: "1px solid var(--border-hairline)" };
|
||||
const rowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" };
|
||||
const linkBtn = { background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left", font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" };
|
||||
const emptyBox = { padding: "22px 16px", textAlign: "center", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" };
|
||||
const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", borderBottom: "1px solid var(--border-hairline)", background: "var(--surface-card)" };
|
||||
const td = { padding: "0 var(--pad-cell-x)", height: 48, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle", borderTop: "1px solid var(--border-hairline)" };
|
||||
const settingsRowMain = { flex: "1 1 auto", minWidth: 0 };
|
||||
const settingsRowLabel = { font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" };
|
||||
const settingsRow = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, padding: "0 16px", height: 52, borderTop: "1px solid var(--border-hairline)" };
|
||||
|
||||
function ViewToggle({ view, setView }) {
|
||||
return (
|
||||
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
|
||||
{[{ v: "list", l: "Groups" }, { v: "editor", l: "Editor" }].map((o) => {
|
||||
const on = o.v === view;
|
||||
return (
|
||||
<button key={o.v} type="button" onClick={() => setView(o.v)}
|
||||
style={{ height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
|
||||
{o.l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplateList({ view, setView }) {
|
||||
const total = GROUPS.reduce((n, g) => n + g.templates.length, 0);
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Badge tone="neutral">{total} deco templates</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
{GROUPS.map((g) => (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco template</Button>
|
||||
<Tooltip label="Delete group">
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{g.templates.length === 0 ? (
|
||||
<div style={emptyBox}>No deco templates in this group.</div>
|
||||
) : (
|
||||
g.templates.map((t, i) => (
|
||||
<div key={t.id} style={{ ...flushRow, borderTop: i === 0 ? "none" : flushRow.borderTop }}>
|
||||
<button type="button" style={linkBtn} onClick={() => setView("editor")}>{t.name}</button>
|
||||
<Tooltip label="Delete deco template">
|
||||
<IconButton size="sm" variant="ghost" title="Delete deco template"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplateEditor({ view, setView }) {
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All deco templates</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Default / Edit deco template</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ViewToggle view={view} setView={setView} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco template</Button>
|
||||
</div>
|
||||
|
||||
<Card padded={false} title="Deco template">
|
||||
<div style={{ ...settingsRow, borderTop: "none" }}>
|
||||
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template group</div></div>
|
||||
<div style={{ flex: "0 0 360px" }}><Input disabled size="sm" value="Default" fullWidth /></div>
|
||||
</div>
|
||||
<div style={settingsRow}>
|
||||
<div style={settingsRowMain}><div style={settingsRowLabel}>Deco template name</div></div>
|
||||
<div style={{ flex: "0 0 360px" }}><Input size="sm" value="Standard weekday" fullWidth /></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Add content">
|
||||
<div style={{ display: "flex", gap: 12, padding: 16, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div style={{ minWidth: 200 }}><Select label="Deco group" options={DECO_GROUP_OPTIONS} value="" /></div>
|
||||
<div style={{ minWidth: 200 }}><Select label="Deco" options={DECO_OPTIONS} value="" /></div>
|
||||
<div style={{ minWidth: 140 }}><Select label="Start time" options={START_TIME_OPTIONS} value="18:00:00" /></div>
|
||||
<div style={{ width: 90 }}><Input label="Hours" type="number" value="1" /></div>
|
||||
<div style={{ width: 110 }}><Select label="Minutes" options={DURATION_MINUTE_OPTIONS} value="30" /></div>
|
||||
<Button size="sm" disabled startIcon={<Ico n="Plus" s={14} />}>Add</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Deco template items">
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead><tr>
|
||||
<th style={th}>Time</th>
|
||||
<th style={th}>Deco</th>
|
||||
<th style={{ ...th, textAlign: "right" }}></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{DRAFT_ITEMS.map((it) => (
|
||||
<tr key={it.key}>
|
||||
<td style={{ ...td, ...mono, width: 130 }}>{it.time}</td>
|
||||
<td style={td}>{it.deco}</td>
|
||||
<td style={{ ...td, width: 60, textAlign: "right" }}>
|
||||
<Tooltip label="Remove"><IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecoTemplates() {
|
||||
const [view, setView] = React.useState("list");
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{view === "list" ? <DecoTemplateList view={view} setView={setView} /> : <DecoTemplateEditor view={view} setView={setView} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDecoTemplates = DecoTemplates;
|
||||
})();
|
||||
@@ -0,0 +1,341 @@
|
||||
// Decos screen — group'd deco library (list+card view) with a full-detail editor
|
||||
// for watermark / graphics / break-content / filler override rules per deco.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 1, name: "Default" },
|
||||
{ id: 2, name: "Weeknights" },
|
||||
{ id: 3, name: "Retro Block" },
|
||||
];
|
||||
|
||||
const DECOS = [
|
||||
{ id: 101, groupId: 1, name: "Standard Bug" },
|
||||
{ id: 102, groupId: 1, name: "No Overlay" },
|
||||
{ id: 201, groupId: 2, name: "News Ticker" },
|
||||
{ id: 202, groupId: 2, name: "Prime Time Bumper" },
|
||||
{ id: 301, groupId: 3, name: "Retro Bumper" },
|
||||
{ id: 302, groupId: 3, name: "VHS Overlay" },
|
||||
{ id: 303, groupId: 3, name: "Static Break Loop" },
|
||||
];
|
||||
|
||||
const WATERMARK_MODE_OPTIONS = [
|
||||
{ label: "Inherit", value: "Inherit" },
|
||||
{ label: "Disable", value: "Disable" },
|
||||
{ label: "Replace", value: "Override" },
|
||||
{ label: "Merge", value: "Merge" },
|
||||
];
|
||||
const BASIC_MODE_OPTIONS = [
|
||||
{ label: "Inherit", value: "Inherit" },
|
||||
{ label: "Disable", value: "Disable" },
|
||||
{ label: "Override", value: "Override" },
|
||||
];
|
||||
const FILLER_TYPE_OPTIONS = [
|
||||
{ label: "Collection", value: "Collection" },
|
||||
{ label: "Television Show", value: "TelevisionShow" },
|
||||
{ label: "Television Season", value: "TelevisionSeason" },
|
||||
{ label: "Artist", value: "Artist" },
|
||||
{ label: "Multi Collection", value: "MultiCollection" },
|
||||
{ label: "Smart Collection", value: "SmartCollection" },
|
||||
];
|
||||
const PLACEMENT_LABEL = {
|
||||
BlockStart: "Block Start",
|
||||
BlockFinish: "Block Finish",
|
||||
BetweenBlockItems: "Between Block Items",
|
||||
ChapterMarkers: "At Chapter Markers",
|
||||
};
|
||||
|
||||
const WATERMARKS = [
|
||||
{ id: 1, name: "Bug — bottom right" },
|
||||
{ id: 2, name: "Bug — bottom left, translucent" },
|
||||
{ id: 3, name: "Station ID corner mark" },
|
||||
];
|
||||
const GRAPHICS = [
|
||||
{ id: 1, name: "Now/Next lower third" },
|
||||
{ id: 2, name: "Weather crawl" },
|
||||
{ id: 3, name: "Retro scanlines overlay" },
|
||||
];
|
||||
const BREAK_ITEMS = [
|
||||
{ key: "b1", placement: "BlockStart", collectionType: "Playlist", selectionName: "Bumper Playlist — Retro" },
|
||||
{ key: "b2", placement: "BetweenBlockItems", collectionType: "Collection", selectionName: "Commercial Bumps 90s" },
|
||||
{ key: "b3", placement: "ChapterMarkers", collectionType: "Playlist", selectionName: "Station ID Loop" },
|
||||
];
|
||||
|
||||
const EDIT_DRAFT = {
|
||||
groupName: "Retro Block",
|
||||
name: "Retro Bumper",
|
||||
watermarkMode: "Override",
|
||||
watermarkIds: [1, 3],
|
||||
useWatermarkDuringFiller: true,
|
||||
graphicsMode: "Merge",
|
||||
graphicsIds: [3],
|
||||
useGraphicsDuringFiller: false,
|
||||
breakContentMode: "Override",
|
||||
defaultFillerMode: "Override",
|
||||
defaultFillerType: "Collection",
|
||||
defaultFillerName: "Retro Filler Loop",
|
||||
defaultFillerTrimToFit: true,
|
||||
deadAirMode: "Inherit",
|
||||
};
|
||||
|
||||
function Row({ label, children, control = 360, first = false }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
padding: "12px var(--pad-cell-x)",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
|
||||
<span style={{ font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlushRow({ children, first = false }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "0 var(--pad-cell-x)",
|
||||
height: 44,
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyNote({ children }) {
|
||||
return (
|
||||
<div style={{ padding: "18px var(--pad-cell-x)", font: "var(--text-sm)/1.4 var(--font-sans)", color: "var(--text-faint)" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelect({ options, selected, disabled }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{options.map((o) => (
|
||||
<Checkbox key={o.id} checked={selected.includes(o.id)} disabled={disabled} label={o.name} onChange={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Decos() {
|
||||
const [screen, setScreen] = React.useState("list"); // "list" | "edit"
|
||||
const [selectedBreak, setSelectedBreak] = React.useState(BREAK_ITEMS[0].key);
|
||||
const [draft, setDraft] = React.useState(EDIT_DRAFT);
|
||||
|
||||
const patch = (p) => setDraft((d) => ({ ...d, ...p }));
|
||||
const watermarkActive = draft.watermarkMode === "Override" || draft.watermarkMode === "Merge";
|
||||
const graphicsActive = draft.graphicsMode === "Override" || draft.graphicsMode === "Merge";
|
||||
const selectedBreakItem = BREAK_ITEMS.find((b) => b.key === selectedBreak) || null;
|
||||
|
||||
const openEditor = () => setScreen("edit");
|
||||
const backToList = () => setScreen("list");
|
||||
|
||||
if (screen === "edit") {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "auto" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58, position: "sticky", top: 0, background: "var(--surface-app)", zIndex: 3 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={backToList}>All decos</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{draft.groupName} / Edit deco</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>Save deco</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16, maxWidth: 900 }}>
|
||||
<Card padded={false} title="Deco">
|
||||
<Row label="Deco group" first>
|
||||
<Input size="sm" disabled value={draft.groupName} />
|
||||
</Row>
|
||||
<Row label="Deco name">
|
||||
<Input size="sm" value={draft.name} onChange={() => {}} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Watermark">
|
||||
<Row label="Watermark mode" first>
|
||||
<Select value={draft.watermarkMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ watermarkMode: e.target.value })} />
|
||||
</Row>
|
||||
<Row label="Watermarks">
|
||||
<MultiSelect options={WATERMARKS} selected={draft.watermarkIds} disabled={!watermarkActive} />
|
||||
</Row>
|
||||
<Row label="Use watermark during filler">
|
||||
<Checkbox checked={draft.useWatermarkDuringFiller} disabled={draft.watermarkMode !== "Override"} onChange={(v) => patch({ useWatermarkDuringFiller: v })} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Graphics elements">
|
||||
<Row label="Graphics elements mode" first>
|
||||
<Select value={draft.graphicsMode} options={WATERMARK_MODE_OPTIONS} onChange={(e) => patch({ graphicsMode: e.target.value })} />
|
||||
</Row>
|
||||
<Row label="Graphics elements">
|
||||
<MultiSelect options={GRAPHICS} selected={draft.graphicsIds} disabled={!graphicsActive} />
|
||||
</Row>
|
||||
<Row label="Use graphics elements during filler">
|
||||
<Checkbox checked={draft.useGraphicsDuringFiller} disabled={draft.graphicsMode !== "Override"} onChange={(v) => patch({ useGraphicsDuringFiller: v })} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
padded={false}
|
||||
title="Break content"
|
||||
actions={draft.breakContentMode === "Override" ? <Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>Add break content</Button> : undefined}
|
||||
>
|
||||
<Row label="Break content mode" first>
|
||||
<Select value={draft.breakContentMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ breakContentMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.breakContentMode === "Override" && (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Placement", "Type", "Name", ""].map((h) => (
|
||||
<th key={h} style={{ textAlign: "left", padding: "0 var(--pad-cell-x)", height: 32, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{BREAK_ITEMS.map((item) => {
|
||||
const active = item.key === selectedBreak;
|
||||
return (
|
||||
<tr key={item.key} onClick={() => setSelectedBreak(item.key)} style={{ cursor: "pointer", background: active ? "var(--ctv-accent-soft)" : "transparent", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{PLACEMENT_LABEL[item.placement]}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.collectionType}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{item.selectionName}</td>
|
||||
<td style={{ padding: "0 var(--pad-cell-x)", height: 44, textAlign: "right" }} onClick={(e) => e.stopPropagation()}>
|
||||
<IconButton size="sm" variant="ghost" title="Remove"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{draft.breakContentMode === "Override" && selectedBreakItem && (
|
||||
<Card padded={false} title="Break content item">
|
||||
<Row label="Placement" first>
|
||||
<Select value={selectedBreakItem.placement} options={Object.entries(PLACEMENT_LABEL).map(([value, label]) => ({ value, label }))} onChange={() => {}} />
|
||||
</Row>
|
||||
{selectedBreakItem.collectionType === "Playlist" ? (
|
||||
<React.Fragment>
|
||||
<Row label="Playlist group">
|
||||
<Select value="retro" options={[{ value: "retro", label: "Retro Bumpers" }, { value: "news", label: "News Break" }]} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row label="Playlist">
|
||||
<Select value="loop1" options={[{ value: "loop1", label: "Bumper Playlist — Retro" }, { value: "loop2", label: "Late Night Loop" }]} onChange={() => {}} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Row label="Selection">
|
||||
<Badge tone="accent">{selectedBreakItem.selectionName}</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padded={false} title="Default filler">
|
||||
<Row label="Default filler mode" first>
|
||||
<Select value={draft.defaultFillerMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ defaultFillerMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.defaultFillerMode === "Override" && (
|
||||
<React.Fragment>
|
||||
<Row label="Collection type">
|
||||
<Select value={draft.defaultFillerType} options={FILLER_TYPE_OPTIONS} onChange={(e) => patch({ defaultFillerType: e.target.value })} />
|
||||
</Row>
|
||||
<Row label={FILLER_TYPE_OPTIONS.find((o) => o.value === draft.defaultFillerType)?.label}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Badge tone="accent">{draft.defaultFillerName}</Badge>
|
||||
<Input size="sm" placeholder="Type to search…" value="" onChange={() => {}} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Trim to fit">
|
||||
<Checkbox checked={draft.defaultFillerTrimToFit} onChange={(v) => patch({ defaultFillerTrimToFit: v })} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Dead air fallback">
|
||||
<Row label="Dead air fallback mode" first>
|
||||
<Select value={draft.deadAirMode} options={BASIC_MODE_OPTIONS} onChange={(e) => patch({ deadAirMode: e.target.value })} />
|
||||
</Row>
|
||||
{draft.deadAirMode === "Override" && (
|
||||
<Row label="Collection">
|
||||
<Badge tone="accent">(none)</Badge>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* action bar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{DECOS.length} decos</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="FolderPlus" s={14} />}>New group</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{GROUPS.map((g) => {
|
||||
const groupDecos = DECOS.filter((d) => d.groupId === g.id);
|
||||
return (
|
||||
<Card
|
||||
key={g.id}
|
||||
padded={false}
|
||||
title={g.name}
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={14} />}>New deco</Button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete group"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{groupDecos.length === 0 ? (
|
||||
<EmptyNote>No decos in this group.</EmptyNote>
|
||||
) : (
|
||||
groupDecos.map((d, i) => (
|
||||
<FlushRow key={d.id} first={i === 0}>
|
||||
<Ico n="Palette" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
style={{ flex: 1, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}
|
||||
>
|
||||
{d.name}
|
||||
</button>
|
||||
<IconButton size="sm" variant="ghost" title="Delete deco"><Ico n="Trash2" s={14} /></IconButton>
|
||||
</FlushRow>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVDecos = Decos;
|
||||
})();
|
||||
@@ -1,108 +0,0 @@
|
||||
// EPG / Guide grid — spec 4.4: channels down the left, time across the top,
|
||||
// programme blocks, a vertical "now" marker, current programme uses the focal
|
||||
// (live) color, filler muted, jump-to-now + time scrubber.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Select, ChannelLogo } = NS;
|
||||
const Ico = window.Ico;
|
||||
const D = window.CTV_DATA;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SLOT_W = 156; // px per 30-min slot
|
||||
const RAIL_W = 196; // channel rail
|
||||
const ROW_H = 82;
|
||||
const HEAD_H = 34;
|
||||
const NOW_SLOTS = 31 / 30; // 20:31 → just past the first slot
|
||||
|
||||
const catTone = { Filler: "var(--text-disabled)" };
|
||||
|
||||
function Programme({ p }) {
|
||||
const filler = p.filler;
|
||||
const live = p.live;
|
||||
return (
|
||||
<div title={`${p.title}${p.sub ? " — " + p.sub : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: p.offset * SLOT_W + 3,
|
||||
width: p.span * SLOT_W - 6,
|
||||
top: 5, bottom: 5,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: "8px 10px",
|
||||
overflow: "hidden",
|
||||
background: live ? "var(--ctv-live-soft)" : filler ? "var(--ctv-bg-sunken)" : "var(--ctv-surface-2)",
|
||||
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
|
||||
display: "flex", flexDirection: "column", gap: 3,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
|
||||
{live && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--status-live)", flex: "0 0 auto" }} />}
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: filler ? "var(--text-disabled)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.title}</span>
|
||||
</div>
|
||||
{p.sub && p.span > 1 && (
|
||||
<span style={{ font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.sub}</span>
|
||||
)}
|
||||
{p.span > 1 && (
|
||||
<span style={{ marginTop: "auto", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.cat}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Epg() {
|
||||
const [filter, setFilter] = React.useState("All channels");
|
||||
const totalW = RAIL_W + D.epgSlots.length * SLOT_W;
|
||||
const nowX = RAIL_W + NOW_SLOTS * SLOT_W;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
|
||||
<div style={{ width: 190 }}>
|
||||
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value)} options={["All channels", "Favorites", "News", "Kids", "Movies"]} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>20:00</span>
|
||||
<input type="range" min="0" max="100" defaultValue="12" style={{ flex: 1, accentColor: "var(--ctv-accent)", maxWidth: 320 }} />
|
||||
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>02:00</span>
|
||||
</div>
|
||||
<Badge tone="accent" dot>Now 20:31</Badge>
|
||||
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
|
||||
</div>
|
||||
|
||||
{/* grid */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", background: "var(--surface-app)" }}>
|
||||
<div style={{ position: "relative", width: totalW, minWidth: "100%" }}>
|
||||
{/* time header */}
|
||||
<div style={{ display: "flex", position: "sticky", top: 0, zIndex: 3, height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 4, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
|
||||
{D.epgSlots.map((t, i) => (
|
||||
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 10px", borderRight: "1px solid var(--border-hairline)", ...mono, fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* now marker */}
|
||||
<div style={{ position: "absolute", top: HEAD_H, bottom: 0, left: nowX, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
|
||||
<span style={{ position: "absolute", top: -6, left: -4, width: 10, height: 10, borderRadius: "50%", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
|
||||
</div>
|
||||
|
||||
{/* channel rows */}
|
||||
{D.epg.map((ch, i) => (
|
||||
<div key={ch.num} style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
{/* rail */}
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "0 12px", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
|
||||
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)", width: 26, flex: "0 0 auto" }}>{ch.num}</span>
|
||||
<ChannelLogo name={ch.name} size={30} />
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
|
||||
</div>
|
||||
{/* programme track */}
|
||||
<div style={{ position: "relative", flex: 1, background: i % 2 ? "rgba(255,255,255,0.008)" : "transparent" }}>
|
||||
{ch.programmes.map((p, j) => <Programme key={j} p={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
window.CTVEpg = Epg;
|
||||
})();
|
||||
@@ -0,0 +1,335 @@
|
||||
// FFmpeg Profiles screen — a compact profile list (icon + name + resolution/codec badges +
|
||||
// copy/delete) that drills into a General/Video/Audio settings-row editor, mirroring the
|
||||
// ChannelEditScreen form language. Static mockup toggles list ⇄ editor for both states.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const PROFILES = [
|
||||
{ id: 1, name: "1080p30 — NVENC", resolution: "1920x1080", video: "hevc", audio: "aac" },
|
||||
{ id: 2, name: "1080p30 — Software", resolution: "1920x1080", video: "h264", audio: "aac" },
|
||||
{ id: 3, name: "720p30 — VAAPI", resolution: "1280x720", video: "h264", audio: "aac" },
|
||||
{ id: 4, name: "4K HDR — NVENC AV1", resolution: "3840x2160", video: "av1", audio: "ac3" },
|
||||
{ id: 5, name: "Retro 480i — Copy", resolution: "720x480", video: "mpeg-2 video", audio: "aac" },
|
||||
{ id: 6, name: "Music Channel — Audio only", resolution: "1280x720", video: "h264", audio: "aac (latm)" },
|
||||
{ id: 7, name: "QSV Balanced", resolution: "1920x1080", video: "hevc", audio: "aac" },
|
||||
{ id: 8, name: "Low-bitrate Mobile", resolution: "854x480", video: "h264", audio: "aac" },
|
||||
];
|
||||
|
||||
const SCALING_OPTIONS = [
|
||||
{ label: "Scale and Pad", value: "ScaleAndPad" },
|
||||
{ label: "Stretch", value: "Stretch" },
|
||||
{ label: "Crop", value: "Crop" },
|
||||
];
|
||||
const PAD_MODE_OPTIONS = [
|
||||
{ label: "Hardware If Possible", value: "HardwareIfPossible" },
|
||||
{ label: "Software", value: "Software" },
|
||||
];
|
||||
const RESOLUTION_OPTIONS = ["1920x1080", "1280x720", "3840x2160", "854x480", "720x480"];
|
||||
const VIDEO_FORMAT_OPTIONS = [
|
||||
{ label: "h264", value: "H264" },
|
||||
{ label: "hevc", value: "Hevc" },
|
||||
{ label: "mpeg-2", value: "Mpeg2Video" },
|
||||
{ label: "av1", value: "Av1" },
|
||||
];
|
||||
const VIDEO_PROFILE_OPTIONS = [
|
||||
{ label: "main", value: "main" },
|
||||
{ label: "high", value: "high" },
|
||||
{ label: "high444p", value: "high444p" },
|
||||
];
|
||||
const PRESET_OPTIONS = [{ label: "(none)", value: "" }, { label: "llhp", value: "llhp" }, { label: "llhq", value: "llhq" }];
|
||||
const BIT_DEPTH_OPTIONS = [
|
||||
{ label: "8-bit", value: "EightBit" },
|
||||
{ label: "10-bit", value: "TenBit" },
|
||||
];
|
||||
const HWACCEL_OPTIONS = ["None", "Nvenc", "Qsv", "Vaapi", "Amf", "VideoToolbox"];
|
||||
const TONEMAP_OPTIONS = ["Linear", "Clip", "Gamma", "Reinhard", "Mobius", "Hable"];
|
||||
const AUDIO_FORMAT_OPTIONS = [
|
||||
{ label: "aac", value: "Aac" },
|
||||
{ label: "ac3", value: "Ac3" },
|
||||
{ label: "aac (latm)", value: "AacLatm" },
|
||||
];
|
||||
const LOUDNESS_OPTIONS = [
|
||||
{ label: "Off", value: "Off" },
|
||||
{ label: "loudnorm", value: "LoudNorm" },
|
||||
];
|
||||
|
||||
function Row({ label, help, control = 320, first = false, children }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 24,
|
||||
padding: "13px 20px",
|
||||
borderTop: first ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: "1 1 auto", minWidth: 0, paddingTop: 5 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 460 }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBar({ children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileList({ onOpen, onAdd }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Badge tone="neutral">{PROFILES.length} profiles</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={onAdd}>
|
||||
New profile
|
||||
</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{PROFILES.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "0 16px",
|
||||
height: "var(--row-h, 52px)",
|
||||
minHeight: 52,
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<Ico n="SlidersHorizontal" s={15} color="var(--ctv-accent)" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(p)}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)",
|
||||
color: "var(--ctv-accent)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<Badge tone="neutral">{p.resolution}</Badge>
|
||||
<Badge tone="neutral">{`${p.video} / ${p.audio}`}</Badge>
|
||||
<Tooltip label={`Copy ${p.name}`}>
|
||||
<IconButton size="sm" variant="ghost" title={`Copy ${p.name}`} onClick={() => onAdd()}>
|
||||
<Ico n="Copy" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<IconButton size="sm" variant="ghost" title="Delete">
|
||||
<Ico n="Trash2" s={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileEditor({ profile, isAdd, onBack }) {
|
||||
const [normalizeVideo, setNormalizeVideo] = React.useState(true);
|
||||
const [normalizeAudio, setNormalizeAudio] = React.useState(true);
|
||||
const [hwaccel, setHwaccel] = React.useState(profile ? (profile.video === "hevc" ? "Nvenc" : "None") : "None");
|
||||
const [loudness, setLoudness] = React.useState("Off");
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ActionBar>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={onBack}>
|
||||
All profiles
|
||||
</Button>
|
||||
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{isAdd ? "New FFmpeg profile" : "Edit FFmpeg profile"}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>
|
||||
{isAdd ? "Add profile" : "Save profile"}
|
||||
</Button>
|
||||
</ActionBar>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px", display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Card padded={false} title="General">
|
||||
<Row first label="Name" control={360}>
|
||||
<Input size="sm" value={isAdd ? "" : profile.name} placeholder="e.g. 1080p30 Software" />
|
||||
</Row>
|
||||
<Row label="Thread count" control={200}>
|
||||
<Input size="sm" type="number" trailing="threads" value="0" />
|
||||
</Row>
|
||||
<Row label="Normalize audio" control={200}>
|
||||
<Checkbox checked={normalizeAudio} onChange={setNormalizeAudio} />
|
||||
</Row>
|
||||
<Row label="Normalize video" control={200}>
|
||||
<Checkbox checked={normalizeVideo} onChange={setNormalizeVideo} />
|
||||
</Row>
|
||||
<Row label="Preferred resolution" control={360}>
|
||||
<Select value={isAdd ? RESOLUTION_OPTIONS[0] : profile.resolution} options={RESOLUTION_OPTIONS} />
|
||||
</Row>
|
||||
{normalizeVideo && (
|
||||
<React.Fragment>
|
||||
<Row label="Scaling behavior" control={360}>
|
||||
<Select value="ScaleAndPad" options={SCALING_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Pad mode" help="Hardware padding only applies with VAAPI; otherwise software padding is used.">
|
||||
<Select disabled={hwaccel !== "Vaapi"} value="Software" options={PAD_MODE_OPTIONS} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Video">
|
||||
{normalizeVideo ? (
|
||||
<React.Fragment>
|
||||
<Row first label="Format" control={360}>
|
||||
<Select value={isAdd ? "H264" : "Hevc"} options={VIDEO_FORMAT_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Profile" control={360}>
|
||||
<Select value="high" options={VIDEO_PROFILE_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Preset" control={360}>
|
||||
<Select disabled={hwaccel === "None"} value={hwaccel === "None" ? "" : "llhq"} options={PRESET_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Allow B-frames" control={200}>
|
||||
<Checkbox checked={false} />
|
||||
</Row>
|
||||
<Row label="Bit depth" control={360}>
|
||||
<Select value="EightBit" options={BIT_DEPTH_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Bitrate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit/s" value="2000" />
|
||||
</Row>
|
||||
<Row label="Buffer size" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit" value="4000" />
|
||||
</Row>
|
||||
<Row label="Hardware acceleration" control={360}>
|
||||
<Select value={hwaccel} options={HWACCEL_OPTIONS} onChange={(e) => setHwaccel(e.target.value)} />
|
||||
</Row>
|
||||
{hwaccel === "Vaapi" && (
|
||||
<React.Fragment>
|
||||
<Row label="VAAPI driver" control={360}>
|
||||
<Select value="Default" options={["Default", "iHD", "i965", "RadeonSI", "Nouveau"]} />
|
||||
</Row>
|
||||
<Row label="VAAPI display" control={360} help="Rendering display, e.g. drm.">
|
||||
<Input size="sm" value="drm" />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{(hwaccel === "Vaapi" || hwaccel === "Qsv") && (
|
||||
<Row label={hwaccel === "Vaapi" ? "VAAPI device" : "QSV device"} control={360} help="Render device, e.g. /dev/dri/renderD128.">
|
||||
<Input size="sm" value="/dev/dri/renderD128" />
|
||||
</Row>
|
||||
)}
|
||||
{hwaccel === "Qsv" ? (
|
||||
<Row label="QSV extra hardware frames" control={200}>
|
||||
<Input size="sm" type="number" value="64" />
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Tonemap algorithm" control={360}>
|
||||
<Select value="Linear" options={TONEMAP_OPTIONS} />
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Normalize frame rate" control={200}>
|
||||
<Checkbox checked={false} />
|
||||
</Row>
|
||||
<Row label="Normalize colors" control={200}>
|
||||
<Checkbox checked={true} />
|
||||
</Row>
|
||||
<Row label="Auto deinterlace video" control={200}>
|
||||
<Checkbox checked={true} />
|
||||
</Row>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Video will be copied as-is, including timestamps, which will cause issues with most clients.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padded={false} title="Audio">
|
||||
{normalizeAudio ? (
|
||||
<React.Fragment>
|
||||
<Row first label="Format" control={360}>
|
||||
<Select value="Aac" options={AUDIO_FORMAT_OPTIONS} />
|
||||
</Row>
|
||||
<Row label="Bitrate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit/s" value="192" />
|
||||
</Row>
|
||||
<Row label="Buffer size" control={220}>
|
||||
<Input size="sm" type="number" trailing="kBit" value="384" />
|
||||
</Row>
|
||||
<Row label="Channels" control={200}>
|
||||
<Input size="sm" type="number" value="2" />
|
||||
</Row>
|
||||
<Row label="Sample rate" control={220}>
|
||||
<Input size="sm" type="number" trailing="kHz" value="48" />
|
||||
</Row>
|
||||
<Row label="Normalize loudness" control={360}>
|
||||
<Select value={loudness} options={LOUDNESS_OPTIONS} onChange={(e) => setLoudness(e.target.value)} />
|
||||
</Row>
|
||||
{loudness === "LoudNorm" && (
|
||||
<Row label="Target loudness" control={220}>
|
||||
<Input size="sm" type="number" trailing="LUFS" value="-23" />
|
||||
</Row>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: 16, padding: "10px 12px", borderRadius: "var(--radius-sm)", background: "var(--ctv-warn-soft)", border: "1px solid var(--border-hairline)" }}>
|
||||
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
|
||||
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
|
||||
Audio will be copied as-is, including timestamps, which will cause issues with most clients.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FFmpegProfiles() {
|
||||
const [screen, setScreen] = React.useState({ kind: "list" });
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{screen.kind === "list" ? (
|
||||
<ProfileList onOpen={(p) => setScreen({ kind: "edit", profile: p })} onAdd={() => setScreen({ kind: "add" })} />
|
||||
) : (
|
||||
<ProfileEditor
|
||||
profile={screen.profile}
|
||||
isAdd={screen.kind === "add"}
|
||||
onBack={() => setScreen({ kind: "list" })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVFFmpegProfiles = FFmpegProfiles;
|
||||
})();
|
||||
@@ -0,0 +1,146 @@
|
||||
// Filler Presets screen — a flat list of reusable pre/mid/post-roll filler
|
||||
// rules with a slide-in editor (kind/mode/collection form), mirroring the
|
||||
// live SPA's list⇄editor route pair inside one static mockup.
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Button, IconButton, Badge, Card, Input, Select, Checkbox, Tooltip } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const PRESETS = [
|
||||
{ id: 1, name: "Network Bumper — Short", kind: "Pre-Roll", mode: "Count", detail: "3 clips", collection: "Bumpers / Short" },
|
||||
{ id: 2, name: "Station ID", kind: "Pre-Roll", mode: "Duration", detail: "0:00:15", collection: "Station IDs" },
|
||||
{ id: 3, name: "Ad Break — Standard", kind: "Mid-Roll", mode: "Count", detail: "2 clips", collection: "Commercials 2024" },
|
||||
{ id: 4, name: "Ad Break — Long Form", kind: "Mid-Roll", mode: "RandomCount", detail: "1–3 clips", collection: "Commercials 2024" },
|
||||
{ id: 5, name: "Coming Up Next", kind: "Post-Roll", mode: "Count", detail: "1 clip", collection: "Promos" },
|
||||
{ id: 6, name: "Sign-Off Reel", kind: "Tail", mode: "—", detail: "fills remainder", collection: "Late Night Bumpers" },
|
||||
{ id: 7, name: "Pad to Top of Hour", kind: "Mid-Roll", mode: "Pad", detail: "pad 15m", collection: "Filler Loops" },
|
||||
{ id: 8, name: "Dead Air Fallback", kind: "Fallback", mode: "—", detail: "always available", collection: "Please Stand By" },
|
||||
{ id: 9, name: "Retro Bumper Pack", kind: "Pre-Roll", mode: "Count", detail: "4 clips", collection: "Retro Bumpers" },
|
||||
];
|
||||
|
||||
const KIND_TONE = { "Pre-Roll": "accent", "Mid-Roll": "accent", "Post-Roll": "neutral", Tail: "neutral", Fallback: "warn" };
|
||||
|
||||
const KIND_OPTIONS = ["Pre-Roll", "Mid-Roll", "Post-Roll", "Tail", "Fallback"];
|
||||
const MODE_OPTIONS = ["Duration", "Count", "Pad", "Random Count"];
|
||||
const PAD_OPTIONS = ["5", "10", "15", "30"];
|
||||
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Playlist"];
|
||||
|
||||
function Row({ label, help, control = 320, first = false, children }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 24, padding: "14px var(--pad-card)", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, paddingTop: 6 }}>
|
||||
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
|
||||
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-faint)", maxWidth: 460 }}>{help}</div>}
|
||||
</div>
|
||||
<div style={{ flex: `0 0 ${control}px` }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FillerPresets() {
|
||||
const [view, setView] = React.useState("list"); // "list" | "edit" | "add"
|
||||
const [active, setActive] = React.useState(PRESETS[2]);
|
||||
const [hover, setHover] = React.useState(null);
|
||||
const [kind, setKind] = React.useState("Mid-Roll");
|
||||
const [fmode, setFmode] = React.useState("Count");
|
||||
|
||||
const openEdit = (p) => { setActive(p); setKind(p.kind); setFmode(p.mode === "—" ? "Count" : p.mode); setView("edit"); };
|
||||
const openAdd = () => { setActive(null); setKind("Pre-Roll"); setFmode("Count"); setView("add"); };
|
||||
|
||||
const modeLocked = kind === "Tail" || kind === "Fallback";
|
||||
|
||||
if (view !== "list") {
|
||||
const isEdit = view === "edit";
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Button size="sm" variant="ghost" startIcon={<Ico n="ArrowLeft" s={14} />} onClick={() => setView("list")}>All filler presets</Button>
|
||||
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{isEdit ? "Edit filler preset" : "New filler preset"}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Check" s={14} />}>{isEdit ? "Save filler preset" : "Add filler preset"}</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false} title="Filler preset">
|
||||
<Row control={360} first label="Name">
|
||||
<Input size="sm" value={active ? active.name : ""} placeholder="e.g. Ad Break — Standard" />
|
||||
</Row>
|
||||
<Row control={360} label="Kind" help={isEdit ? "Kind cannot be changed after creation." : undefined}>
|
||||
<Select disabled={isEdit} value={kind} onChange={(e) => setKind(e.target.value)} options={KIND_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Mode">
|
||||
<Select disabled={modeLocked} value={modeLocked ? "" : fmode} onChange={(e) => setFmode(e.target.value)} options={modeLocked ? [{ value: "", label: "(n/a)" }] : MODE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Duration">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="h" />
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "0" : ""} trailing="m" />
|
||||
<Input size="sm" type="number" disabled={fmode !== "Duration"} value={fmode === "Duration" ? "15" : ""} trailing="s" />
|
||||
</div>
|
||||
</Row>
|
||||
<Row control={200} label="Count">
|
||||
<Input size="sm" type="number" disabled={fmode !== "Count" && fmode !== "Random Count"} value={fmode === "Count" || fmode === "Random Count" ? "2" : ""} />
|
||||
</Row>
|
||||
<Row control={360} label="Pad to nearest minute">
|
||||
<Select disabled={fmode !== "Pad"} value={fmode === "Pad" ? "15" : ""} options={[{ value: "", label: "(none)" }, ...PAD_OPTIONS]} />
|
||||
</Row>
|
||||
<Row control={200} label="Allow watermarks">
|
||||
<Checkbox checked={false} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Collection type">
|
||||
<Select value="Collection" options={COLLECTION_TYPE_OPTIONS} />
|
||||
</Row>
|
||||
<Row control={360} label="Collection">
|
||||
<Select value={active ? active.collection : ""} options={[active ? active.collection : "(none)", "Commercials 2024", "Bumpers / Short", "Promos"]} />
|
||||
</Row>
|
||||
<Row control={200} label="Use chapters as media items" help="Schedule individual chapters instead of entire files.">
|
||||
<Checkbox checked={false} disabled={kind === "Fallback"} onChange={() => {}} />
|
||||
</Row>
|
||||
<Row control={360} label="Expression" help="Mid-roll only: add filler only when this expression is true for a mid-roll point.">
|
||||
<Input size="sm" disabled={kind !== "Mid-Roll"} value={kind === "Mid-Roll" ? "chapter_index % 2 == 0" : ""} />
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
|
||||
<Badge tone="neutral">{PRESETS.length} presets</Badge>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button size="sm" startIcon={<Ico n="Plus" s={14} />} onClick={openAdd}>New filler preset</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
|
||||
<Card padded={false}>
|
||||
{PRESETS.map((p, i) => {
|
||||
const isHover = hover === p.id;
|
||||
return (
|
||||
<div key={p.id} onMouseEnter={() => setHover(p.id)} onMouseLeave={() => setHover(null)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: "0 var(--pad-cell-x)", height: 52, borderTop: i === 0 ? "none" : "1px solid var(--border-hairline)", background: isHover ? "var(--ctv-surface-2)" : "transparent", transition: "background var(--dur-fast)" }}>
|
||||
<Ico n="Film" s={15} color="var(--ctv-accent)" />
|
||||
<button type="button" onClick={() => openEdit(p)}
|
||||
style={{ flex: 1, minWidth: 0, textAlign: "left", background: "none", border: "none", cursor: "pointer", padding: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>
|
||||
{p.name}
|
||||
</button>
|
||||
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)", minWidth: 160, textAlign: "right" }}>{p.collection}</span>
|
||||
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", minWidth: 90, textAlign: "right" }}>{p.detail}</span>
|
||||
<Badge tone={p.mode === "—" ? "neutral" : "neutral"}>{p.mode}</Badge>
|
||||
<Badge tone={KIND_TONE[p.kind]}>{p.kind}</Badge>
|
||||
<div style={{ display: "flex", alignItems: "center", opacity: isHover ? 1 : 0.4, transition: "opacity var(--dur-fast)" }}>
|
||||
<Tooltip label="Delete"><IconButton size="sm" variant="ghost" title="Delete"><Ico n="Trash2" s={14} /></IconButton></Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVFillerPresets = FillerPresets;
|
||||
})();
|
||||
@@ -0,0 +1,181 @@
|
||||
// Guide screen — a horizontally-scrolling EPG grid: sticky channel rail down
|
||||
// the left, a 30-min time ruler across the top, a live "now" marker, and
|
||||
// programme blocks (current-programme highlight, filler dimming, empty rows).
|
||||
(function () {
|
||||
const NS = window.ChicoryTVDesignSystem_eb3b61;
|
||||
const { Badge, Button, ChannelLogo, Select, StatusDot } = NS;
|
||||
const Ico = window.Ico;
|
||||
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
|
||||
|
||||
const SLOT_MIN = 30;
|
||||
const SLOT_W = 156;
|
||||
const RAIL_W = 196;
|
||||
const ROW_H = 82;
|
||||
const HEAD_H = 34;
|
||||
const WINDOW_HOURS = 13;
|
||||
const SLOT_COUNT = (WINDOW_HOURS * 60) / SLOT_MIN;
|
||||
|
||||
const WINDOW_START = new Date(2026, 0, 1, 12, 0, 0);
|
||||
const WINDOW_END = new Date(WINDOW_START.getTime() + WINDOW_HOURS * 60 * 60000);
|
||||
const NOW = new Date(WINDOW_START.getTime() + 312 * 60000); // 5:12 PM — mid-window
|
||||
const nowInWindow = NOW >= WINDOW_START && NOW <= WINDOW_END;
|
||||
const nowMin = (NOW.getTime() - WINDOW_START.getTime()) / 60000;
|
||||
|
||||
function formatTime(d) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
|
||||
function pxFromMin(min) { return (min / SLOT_MIN) * SLOT_W; }
|
||||
|
||||
const slots = Array.from({ length: SLOT_COUNT }, (_, i) => new Date(WINDOW_START.getTime() + i * SLOT_MIN * 60000));
|
||||
const nowOffset = pxFromMin(nowMin);
|
||||
const totalWidth = RAIL_W + slots.length * SLOT_W;
|
||||
const windowPct = nowInWindow ? Math.round((NOW.getTime() - WINDOW_START.getTime()) / (WINDOW_END.getTime() - WINDOW_START.getTime()) * 100) : 0;
|
||||
|
||||
function buildProgrammes(segments) {
|
||||
let t = 0;
|
||||
return segments.map((seg) => {
|
||||
const startMin = t;
|
||||
t += seg.dur;
|
||||
return { title: seg.title, subTitle: seg.sub || null, category: seg.cat || null, filler: !!seg.filler, startMin, stopMin: t };
|
||||
});
|
||||
}
|
||||
|
||||
const NEWS_TITLES = ["Midday Report", "World Markets", "Capitol Watch", "Global Desk", "Primetime Analysis", "Evening Edition", "Nightly Wrap", "Overnight Desk", "World Tonight", "Morning Preview", "Early Edition", "Business Brief", "Weather & Traffic"];
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
number: "1.1", name: "ChicoryTV Family", onAir: true,
|
||||
programmes: buildProgrammes([
|
||||
{ dur: 90, title: "Tom & Jerry — Marathon", cat: "Kids" },
|
||||
{ dur: 60, title: "Bluey — Season 3", cat: "Kids", sub: "The Sleepover" },
|
||||
{ dur: 120, title: "Afternoon Movie: Starlight Express", cat: "Movie" },
|
||||
{ dur: 90, title: "World Tonight — Evening Bulletin", cat: "News", sub: "Live from the newsroom" },
|
||||
{ dur: 120, title: "Prime Time Drama Block", cat: "Drama" },
|
||||
{ dur: 90, title: "Late Night Talk", cat: "Talk" },
|
||||
]),
|
||||
},
|
||||
{
|
||||
number: "2.1", name: "World News Network", onAir: true,
|
||||
programmes: buildProgrammes(NEWS_TITLES.map((t) => ({ dur: 60, title: t, cat: "News" }))),
|
||||
},
|
||||
{
|
||||
number: "3.1", name: "Sitcom Vault", onAir: false,
|
||||
programmes: buildProgrammes([
|
||||
{ dur: 30, title: "The Office — Casino Night", cat: "Sitcom" },
|
||||
{ dur: 30, title: "Parks & Rec — Flu Season", cat: "Sitcom" },
|
||||
{ dur: 30, title: "Community — Modern Warfare", cat: "Sitcom" },
|
||||
{ dur: 30, title: "Brooklyn 99 — Halloween", cat: "Sitcom" },
|
||||
{ dur: 30, title: "The Office — Diversity Day", cat: "Sitcom" },
|
||||
{ dur: 30, title: "Parks & Rec — The Set Up", cat: "Sitcom" },
|
||||
]),
|
||||
},
|
||||
{
|
||||
number: "4.2", name: "Synthpop Radio", onAir: true,
|
||||
programmes: buildProgrammes([
|
||||
{ dur: 180, title: "Retro Hits Mix", cat: "Music", filler: true },
|
||||
{ dur: 180, title: "Live DJ Set — Nova", cat: "Music", sub: "Synthwave hour" },
|
||||
{ dur: 180, title: "Overnight Mix", cat: "Music", filler: true },
|
||||
]),
|
||||
},
|
||||
{
|
||||
number: "5.1", name: "Kids Zone", onAir: false,
|
||||
programmes: buildProgrammes([
|
||||
{ dur: 60, title: "Sesame Street — Episode 4102", cat: "Kids" },
|
||||
{ dur: 30, title: "Bluey — Marathon", cat: "Kids", sub: "Back-to-back episodes" },
|
||||
{ dur: 90, title: "Afternoon Cartoons", cat: "Kids" },
|
||||
]),
|
||||
},
|
||||
{ number: "9.1", name: "Retro Cartoons", onAir: false, programmes: [] },
|
||||
];
|
||||
|
||||
function ProgrammeBlock({ p, live }) {
|
||||
const left = pxFromMin(p.startMin) + 3;
|
||||
const width = Math.max(24, pxFromMin(p.stopMin) - pxFromMin(p.startMin) - 6);
|
||||
return (
|
||||
<div title={p.subTitle ? `${p.title} - ${p.subTitle}` : p.title}
|
||||
style={{
|
||||
position: "absolute", left, width, top: 5, bottom: 5,
|
||||
display: "flex", flexDirection: "column", gap: 3, minWidth: 0, overflow: "hidden",
|
||||
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
|
||||
borderRadius: "var(--radius-sm)",
|
||||
background: live ? "var(--ctv-live-soft)" : p.filler ? "var(--ctv-bg-sunken)" : "var(--surface-raised)",
|
||||
padding: "var(--space-4) var(--space-5)",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", minWidth: 0 }}>
|
||||
{live && <StatusDot status="live" size={6} />}
|
||||
<strong style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: p.filler ? "var(--text-disabled)" : "var(--text-primary)" }}>{p.title}</strong>
|
||||
</div>
|
||||
{p.subTitle && <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)" }}>{p.subTitle}</span>}
|
||||
{p.filler ? (
|
||||
<Badge tone="neutral">Filler</Badge>
|
||||
) : p.category && (
|
||||
<small style={{ marginTop: "auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.category}</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GuideRow({ channel, index }) {
|
||||
return (
|
||||
<div style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 3, display: "flex", alignItems: "center", gap: "var(--space-5)", padding: "0 var(--space-6)", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
|
||||
<code style={{ ...mono, width: 30, flex: "0 0 auto", color: "var(--status-live)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)" }}>{channel.number}</code>
|
||||
<ChannelLogo name={channel.name} size={30} />
|
||||
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{channel.name}</span>
|
||||
{channel.onAir && <StatusDot status="live" size={7} />}
|
||||
</div>
|
||||
<div style={{ position: "relative", flex: 1, minWidth: 0, background: index % 2 ? "color-mix(in srgb, var(--surface-raised) 18%, transparent)" : "transparent" }}>
|
||||
{channel.programmes.length === 0 ? (
|
||||
<span style={{ position: "absolute", top: "50%", left: "var(--space-6)", transform: "translateY(-50%)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No programmes in this window</span>
|
||||
) : channel.programmes.map((p, i) => (
|
||||
<ProgrammeBlock key={i} p={p} live={channel.onAir && nowInWindow && nowMin >= p.startMin && nowMin < p.stopMin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Guide() {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateRows: "auto minmax(0, 1fr)", gap: "var(--space-6)", height: "100%", minHeight: 0, padding: "var(--space-6) 20px" }}>
|
||||
{/* toolbar */}
|
||||
<section style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "var(--space-5)", padding: "var(--space-5) 0", borderBottom: "1px solid var(--border-hairline)" }} aria-label="Guide controls">
|
||||
<div style={{ width: 190, flex: "0 1 190px" }}>
|
||||
<Select disabled label="Channel group" options={["All channels"]} size="sm" value="All channels" />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, maxWidth: 420, flex: "1 1 240px", display: "flex", alignItems: "center", gap: "var(--space-4)" }}>
|
||||
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_START)}</span>
|
||||
<input aria-label="Guide window position" disabled max="100" min="0" type="range" value={windowPct} readOnly style={{ minWidth: 0, flex: 1, accentColor: "var(--action-primary)" }} />
|
||||
<span style={{ ...mono, color: "var(--text-disabled)", fontSize: "var(--text-2xs)" }}>{formatTime(WINDOW_END)}</span>
|
||||
</div>
|
||||
<Badge tone={nowInWindow ? "accent" : "neutral"} dot={nowInWindow}>Now {formatTime(NOW)}</Badge>
|
||||
<Button variant="secondary">Previous</Button>
|
||||
<Button variant="secondary">Next guide window</Button>
|
||||
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
|
||||
</section>
|
||||
|
||||
{/* grid */}
|
||||
<section style={{ minHeight: 420, overflow: "hidden", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-sm)", background: "var(--surface-app)" }} aria-label="Guide timeline">
|
||||
<div style={{ width: "100%", height: "100%", overflow: "auto" }}>
|
||||
<div style={{ position: "relative", width: totalWidth, minWidth: "100%" }}>
|
||||
<div style={{ position: "sticky", top: 0, zIndex: 4, display: "flex", height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
|
||||
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 5, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
|
||||
{slots.map((slot, i) => (
|
||||
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 var(--space-5)", borderRight: "1px solid var(--border-hairline)", ...mono, color: "var(--text-secondary)", fontSize: "var(--text-xs)" }}>{formatTime(slot)}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nowInWindow && (
|
||||
<div aria-hidden="true" style={{ position: "absolute", top: HEAD_H, bottom: 0, left: RAIL_W + nowOffset, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
|
||||
<span style={{ position: "absolute", top: -5, left: -4, width: 10, height: 10, borderRadius: "var(--radius-pill)", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CHANNELS.map((channel, i) => <GuideRow channel={channel} index={i} key={channel.number} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.CTVGuide = Guide;
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user