Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73455aae28 | ||
|
|
e66f49e54a | ||
|
|
07e9d9838d | ||
|
|
f01ffc6254 | ||
|
|
6057bcea7d | ||
|
|
7b903d4bb4 | ||
|
|
7c1a1d5672 | ||
|
|
1891498563 | ||
|
|
7484df1e5b | ||
|
|
52bfdbcdce | ||
|
|
6b3a698283 | ||
|
|
32c0d6a6d3 | ||
|
|
97b6c4bee7 | ||
|
|
032b9d8116 | ||
|
|
6e6fd67fea | ||
|
|
5fb3bb2ebc | ||
|
|
d1cc12e065 | ||
|
|
e6df6ac10a | ||
|
|
e282bcecfa | ||
|
|
b183c6a154 | ||
|
|
c0b20a0ac5 | ||
|
|
482396497e | ||
|
|
4229717575 | ||
|
|
0826198d21 |
@@ -50,6 +50,37 @@ jobs:
|
||||
- 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: Check generated SPA API client
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
@@ -51,3 +52,6 @@ scripts/download-test-content.sh
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
ErsatzTV/wwwroot/app/
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Codex Instructions
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
|
||||
|
||||
Preferred verification commands:
|
||||
|
||||
```bash
|
||||
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
|
||||
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
|
||||
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
|
||||
```
|
||||
|
||||
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkDeleteChannelsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkDeleteChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkMoveChannelsToGroupHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkMoveChannelsToGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
|
||||
if (groupValidation.IsFail)
|
||||
{
|
||||
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
channel.Group = request.Group;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> validationError = ValidateRequest(request);
|
||||
if (validationError.IsSome)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
List<int> channelIds = numberUpdates.Keys.ToList();
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channelsToUpdate.Count != channelIds.Count)
|
||||
{
|
||||
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return new NotFoundError($"Channel {missingId} does not exist.");
|
||||
}
|
||||
|
||||
List<string> requestedNumbers = numberUpdates.Values.ToList();
|
||||
bool numberConflict = await dbContext.Channels
|
||||
.AnyAsync(
|
||||
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
|
||||
cancellationToken);
|
||||
if (numberConflict)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
|
||||
{
|
||||
if (request.Channels.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one channel is required");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel ids must be unique");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
foreach (ChannelSortViewModel channel in request.Channels)
|
||||
{
|
||||
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,15 @@ internal static class Mapper
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.SortNumber,
|
||||
channel.Name,
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfile.Name,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel));
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
|
||||
@@ -0,0 +1,151 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
|
||||
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
|
||||
|
||||
public async Task<List<ChannelStateResponseModel>> Handle(
|
||||
GetChannelStatesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.SortNumber)
|
||||
.ThenBy(c => c.Number)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
|
||||
.ToDictionary(
|
||||
c => c.Id,
|
||||
c =>
|
||||
{
|
||||
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
|
||||
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
|
||||
});
|
||||
|
||||
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
|
||||
// typically just one) with a cheap projection; metadata is hydrated in a second query
|
||||
// for only the covering items' guide groups
|
||||
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
|
||||
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
|
||||
channelLookup.Values.GroupBy(v => v.LookupTime))
|
||||
{
|
||||
DateTime lookupTime = timeGroup.Key;
|
||||
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
|
||||
|
||||
List<CoveringItem> covering = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
|
||||
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
|
||||
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
|
||||
{
|
||||
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
|
||||
if (coveringBySourceAndTime.Count > 0)
|
||||
{
|
||||
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
|
||||
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
|
||||
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
|
||||
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
|
||||
|
||||
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).ImageMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
||||
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
|
||||
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
itemsByGuideEntry = guideEntryItems
|
||||
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
|
||||
}
|
||||
|
||||
return channels
|
||||
.Map(channel =>
|
||||
{
|
||||
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
|
||||
ChannelNowPlayingResponseModel nowPlaying = null;
|
||||
|
||||
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
|
||||
itemsByGuideEntry.TryGetValue(
|
||||
(covering.PlayoutId, covering.GuideGroup),
|
||||
out List<PlayoutItem> guideEntry))
|
||||
{
|
||||
// like the XMLTV guide, surface the program rather than its filler: use the
|
||||
// covering item itself when it is a program part, otherwise the guide entry's
|
||||
// first program item; an entry with no program item (e.g. offline fallback
|
||||
// filler) stays null
|
||||
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
|
||||
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
|
||||
? coveringItem
|
||||
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
|
||||
|
||||
if (displayItem is not null)
|
||||
{
|
||||
DateTime start = guideEntry[0].Start;
|
||||
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
|
||||
|
||||
nowPlaying = new ChannelNowPlayingResponseModel(
|
||||
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
|
||||
new DateTimeOffset(start + offset, TimeSpan.Zero),
|
||||
new DateTimeOffset(finish + offset, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
|
||||
new(fillerPreset.Id, fillerPreset.Name);
|
||||
|
||||
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
|
||||
new(
|
||||
fillerPreset.Id,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
|
||||
@@ -1,22 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Filler.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Filler;
|
||||
|
||||
public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
|
||||
{
|
||||
public async Task<List<FillerPresetResponseModel>> Handle(
|
||||
GetAllFillerPresetsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return fillerPresets.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
|
||||
@@ -1,27 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Graphics.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Graphics;
|
||||
|
||||
public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
|
||||
{
|
||||
public async Task<List<GraphicsElementResponseModel>> Handle(
|
||||
GetAllGraphicsElementsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return graphicsElements
|
||||
.Map(ProjectToViewModel)
|
||||
.OrderBy(e => e.Name == e.FileName)
|
||||
.ThenBy(e => e.Name)
|
||||
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
|
||||
new(
|
||||
result.Title,
|
||||
GetStatus(result.Status),
|
||||
result.Message,
|
||||
result.Link.MatchUnsafe(l => l.Link, () => null));
|
||||
|
||||
private static string GetStatus(HealthCheckStatus status) =>
|
||||
status switch
|
||||
{
|
||||
HealthCheckStatus.Pass => "pass",
|
||||
HealthCheckStatus.Fail => "fail",
|
||||
HealthCheckStatus.Warning => "warn",
|
||||
HealthCheckStatus.Info => "info",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
|
||||
@@ -1,32 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
using static ErsatzTV.Application.Health.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Health;
|
||||
|
||||
public class GetAllHealthCheckResultsForApiHandler
|
||||
: IRequestHandler<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
|
||||
{
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
|
||||
_healthCheckService = healthCheckService;
|
||||
|
||||
public async Task<List<HealthCheckResponseModel>> Handle(
|
||||
GetAllHealthCheckResultsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results
|
||||
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
|
||||
.Map(ProjectToResponseModel)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
|
||||
@@ -1,20 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
|
||||
: IRequestHandler<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
|
||||
{
|
||||
public Task<List<LibraryScanStatusResponseModel>> Handle(
|
||||
GetLibraryScanStatus request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
|
||||
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
|
||||
@@ -1,148 +0,0 @@
|
||||
#nullable enable
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public class GetAllMediaSourcesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
||||
{
|
||||
public async Task<List<MediaSourceResponseModel>> Handle(
|
||||
GetAllMediaSourcesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<MediaSource> mediaSources = await dbContext.MediaSources
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Libraries)
|
||||
.ThenInclude(l => l.Paths)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
|
||||
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
|
||||
|
||||
var result = new List<MediaSourceResponseModel>();
|
||||
foreach (MediaSource mediaSource in mediaSources)
|
||||
{
|
||||
List<MediaSourceLibraryResponseModel> libraryModels = mediaSource.Libraries
|
||||
.Filter(ShouldIncludeLibrary)
|
||||
.OrderBy(l => l.MediaKind)
|
||||
.ThenBy(l => l.Name)
|
||||
.Map(l => new MediaSourceLibraryResponseModel(
|
||||
l.Id,
|
||||
l.Name,
|
||||
l.MediaKind,
|
||||
l.LastScan,
|
||||
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
|
||||
.ToList();
|
||||
|
||||
string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
|
||||
|
||||
result.Add(
|
||||
new MediaSourceResponseModel(
|
||||
mediaSource.Id,
|
||||
GetKind(mediaSource),
|
||||
GetName(mediaSource),
|
||||
address,
|
||||
libraryModels));
|
||||
}
|
||||
|
||||
return result
|
||||
.OrderBy(s => s.Kind == "Local" ? 0 : 1)
|
||||
.ThenBy(s => s.Kind)
|
||||
.ThenBy(s => s.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<int, int>> GetItemCountsByLibrary(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
|
||||
new CommandDefinition(
|
||||
@"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
|
||||
FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
GROUP BY LP.LibraryId",
|
||||
cancellationToken: cancellationToken));
|
||||
|
||||
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var addresses = new Dictionary<int, string>();
|
||||
|
||||
foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Connections)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
|
||||
{
|
||||
addresses[plex.Id] = connection.Uri;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Connections)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
|
||||
{
|
||||
addresses[jellyfin.Id] = connection.Address;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Connections)
|
||||
.ToListAsync(cancellationToken))
|
||||
{
|
||||
foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
|
||||
{
|
||||
addresses[emby.Id] = connection.Address;
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeLibrary(Library library) =>
|
||||
library switch
|
||||
{
|
||||
LocalLibrary => library.Paths.Count > 0,
|
||||
PlexLibrary plex => plex.ShouldSyncItems,
|
||||
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
|
||||
EmbyLibrary emby => emby.ShouldSyncItems,
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static string GetKind(MediaSource mediaSource) =>
|
||||
mediaSource switch
|
||||
{
|
||||
PlexMediaSource => "Plex",
|
||||
JellyfinMediaSource => "Jellyfin",
|
||||
EmbyMediaSource => "Emby",
|
||||
_ => "Local"
|
||||
};
|
||||
|
||||
private static string GetName(MediaSource mediaSource) =>
|
||||
mediaSource switch
|
||||
{
|
||||
PlexMediaSource plex => plex.ServerName,
|
||||
JellyfinMediaSource jellyfin => jellyfin.ServerName,
|
||||
EmbyMediaSource emby => emby.ServerName,
|
||||
_ => "Local"
|
||||
};
|
||||
|
||||
private sealed record LibraryItemCount(long LibraryId, long Count);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
|
||||
.AsNoTracking()
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.BuildStatus)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
|
||||
.MapT(p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Watermarks;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
|
||||
new(watermark.Id, watermark.Name);
|
||||
|
||||
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
|
||||
new(
|
||||
watermark.Id,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
namespace ErsatzTV.Application.Watermarks;
|
||||
|
||||
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
|
||||
@@ -1,22 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Watermarks.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Watermarks;
|
||||
|
||||
public class GetAllWatermarksForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
|
||||
{
|
||||
public async Task<List<WatermarkResponseModel>> Handle(
|
||||
GetAllWatermarksForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
return watermarks.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelNowPlayingResponseModel(
|
||||
string Title,
|
||||
DateTimeOffset StartUtc,
|
||||
DateTimeOffset FinishUtc);
|
||||
@@ -5,8 +5,13 @@ namespace ErsatzTV.Core.Api.Channels;
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
double SortNumber,
|
||||
string Name,
|
||||
string Group,
|
||||
string Categories,
|
||||
[property: JsonProperty("ffmpegProfile")]
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode);
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelStateResponseModel(
|
||||
int ChannelId,
|
||||
string ChannelNumber,
|
||||
bool OnAir,
|
||||
ChannelNowPlayingResponseModel? NowPlaying);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Filler;
|
||||
|
||||
public record FillerPresetResponseModel(int Id, string Name);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Graphics;
|
||||
|
||||
public record GraphicsElementResponseModel(int Id, string Name);
|
||||
@@ -1,8 +0,0 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.Health;
|
||||
|
||||
public record HealthCheckResponseModel(
|
||||
string Title,
|
||||
string Status,
|
||||
string Detail,
|
||||
string? Link);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Libraries;
|
||||
|
||||
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
|
||||
@@ -1,11 +0,0 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record MediaSourceLibraryResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
DateTime? LastScan,
|
||||
int ItemCount);
|
||||
@@ -1,9 +0,0 @@
|
||||
#nullable enable
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record MediaSourceResponseModel(
|
||||
int Id,
|
||||
string Kind,
|
||||
string Name,
|
||||
string? ConnectionAddress,
|
||||
List<MediaSourceLibraryResponseModel> Libraries);
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PagedPlayoutItemsResponseModel(
|
||||
int TotalCount,
|
||||
List<PlayoutItemResponseModel> Page);
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PagedPlayoutsResponseModel(
|
||||
int TotalCount,
|
||||
List<PlayoutListItemResponseModel> Page);
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutBuildStatusResponseModel(
|
||||
DateTimeOffset LastBuild,
|
||||
bool Success,
|
||||
string Message);
|
||||
@@ -1,10 +0,0 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutItemResponseModel(
|
||||
string Title,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
string Duration,
|
||||
FillerKind? FillerKind);
|
||||
@@ -1,13 +0,0 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutListItemResponseModel(
|
||||
int Id,
|
||||
string ChannelNumber,
|
||||
string ChannelName,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ScheduleName,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus);
|
||||
@@ -1,4 +1,3 @@
|
||||
#nullable enable
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
@@ -10,9 +9,8 @@ public record PlayoutResponseModel(
|
||||
string ChannelNumber,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string? ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? BuildStatus)
|
||||
string ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
@@ -21,9 +19,8 @@ public record PlayoutResponseModel(
|
||||
string channelNumber,
|
||||
ChannelPlayoutMode playoutMode,
|
||||
string scheduleName,
|
||||
string? scheduleFile,
|
||||
TimeSpan? dailyRebuildTime,
|
||||
PlayoutBuildStatusResponseModel? buildStatus) =>
|
||||
string scheduleFile,
|
||||
TimeSpan? dailyRebuildTime) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
@@ -32,6 +29,5 @@ public record PlayoutResponseModel(
|
||||
playoutMode,
|
||||
scheduleName,
|
||||
scheduleFile,
|
||||
dailyRebuildTime,
|
||||
buildStatus);
|
||||
dailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Watermarks;
|
||||
|
||||
public record WatermarkResponseModel(int Id, string Name);
|
||||
@@ -1,5 +1,3 @@
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
public interface IScannerProxyService
|
||||
@@ -9,5 +7,4 @@ public interface IScannerProxyService
|
||||
Task Progress(Guid scanId, decimal percentComplete);
|
||||
bool IsActive(Guid scanId);
|
||||
Option<decimal> GetProgress(int libraryId);
|
||||
IReadOnlyList<LibraryScanProgress> GetActiveScans();
|
||||
}
|
||||
|
||||
@@ -49,7 +49,4 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
|
||||
public Option<decimal> GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
|
||||
? progress
|
||||
: Option<decimal>.None;
|
||||
|
||||
public IReadOnlyList<LibraryScanProgress> GetActiveScans() =>
|
||||
_activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private readonly MockFileSystem _fileSystem = new();
|
||||
|
||||
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
int count = await context.Channels.CountAsync();
|
||||
count.ShouldBe(0);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Move_All_Channels_To_Group()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> groups = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => c.Group)
|
||||
.ToListAsync();
|
||||
groups.ShouldBe(["Movies", "Movies"]);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetAllChannelsForApiHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_Management_Table_Fields()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetAll(Arg.Any<CancellationToken>())
|
||||
.Returns([
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Id = 7,
|
||||
Number = "7.1",
|
||||
SortNumber = 7.1,
|
||||
Name = "Retro Cartoons",
|
||||
Group = "Kids",
|
||||
Categories = "animation",
|
||||
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
IsEnabled = false,
|
||||
ShowInEpg = false
|
||||
}
|
||||
]);
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
ChannelResponseModel channel = result.ShouldHaveSingleItem();
|
||||
channel.Id.ShouldBe(7);
|
||||
channel.Number.ShouldBe("7.1");
|
||||
channel.SortNumber.ShouldBe(7.1);
|
||||
channel.Name.ShouldBe("Retro Cartoons");
|
||||
channel.Group.ShouldBe("Kids");
|
||||
channel.Categories.ShouldBe("animation");
|
||||
channel.FFmpegProfile.ShouldBe("HLS 720p");
|
||||
channel.Language.ShouldBe("eng");
|
||||
channel.StreamingMode.ShouldBe("HLS Segmenter");
|
||||
channel.IsEnabled.ShouldBeFalse();
|
||||
channel.ShowInEpg.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelStatesForApiHandlerTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
await SeedChannelWithMovie(start, finish);
|
||||
_segmenter.IsActive("7.1").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.ChannelId.ShouldBe(7);
|
||||
state.ChannelNumber.ShouldBe("7.1");
|
||||
state.OnAir.ShouldBeTrue();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
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);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.OnAir.ShouldBeFalse();
|
||||
state.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
|
||||
{
|
||||
// program part 1 / mid-roll filler / program part 2, all in one guide group
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(12, "12");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 120,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
|
||||
};
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 121,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
|
||||
};
|
||||
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaItem = mediaItem,
|
||||
MediaItemId = mediaItem.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
FillerKind = fillerKind,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.AddRange(
|
||||
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
|
||||
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
|
||||
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Feature Presentation");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(13, "13");
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 130,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
|
||||
};
|
||||
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 132,
|
||||
MediaItem = filler,
|
||||
MediaItemId = filler.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.AddMinutes(-5),
|
||||
Finish = Now.AddMinutes(5),
|
||||
FillerKind = FillerKind.Fallback,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(9, "9");
|
||||
var remoteStream = new RemoteStream
|
||||
{
|
||||
Id = 90,
|
||||
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
|
||||
};
|
||||
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 92,
|
||||
MediaItem = remoteStream,
|
||||
MediaItemId = remoteStream.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.RemoteStreams.Add(remoteStream);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
ChannelStateResponseModel state =
|
||||
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Live Source");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
|
||||
{
|
||||
TimeSpan offset = TimeSpan.FromHours(1);
|
||||
DateTime sourceStart = Now.AddMinutes(-70);
|
||||
DateTime sourceFinish = Now.AddMinutes(-40);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel source = MakeChannel(10, "10");
|
||||
Channel mirror = MakeChannel(11, "11");
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannelId = source.Id;
|
||||
mirror.PlayoutOffset = offset;
|
||||
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 110,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
|
||||
};
|
||||
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 112,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = sourceStart,
|
||||
Finish = sourceFinish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
|
||||
mirrorState.NowPlaying.ShouldNotBeNull();
|
||||
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
|
||||
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
|
||||
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
|
||||
|
||||
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
|
||||
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
|
||||
sourceState.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(7, "7.1");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 10,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
|
||||
};
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 20,
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
Items = []
|
||||
};
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 30,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
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,
|
||||
PreferredAudioLanguageCode = string.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Renumber_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "11.1" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channels = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new { c.Number, c.SortNumber })
|
||||
.ToListAsync();
|
||||
channels[0].Number.ShouldBe("10");
|
||||
channels[0].SortNumber.ShouldBe(10);
|
||||
channels[1].Number.ShouldBe("11.1");
|
||||
channels[1].SortNumber.ShouldBe(11.1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 99, Number = "11" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "10" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
|
||||
numbers.ShouldBe(["5", "6"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Invalid channel number");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
private static BaseError ErrorOf(Option<BaseError> result) =>
|
||||
result.Match(
|
||||
Some: error => error,
|
||||
None: () => throw new AssertionException("Expected an error"));
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Filler;
|
||||
|
||||
[TestFixture]
|
||||
public class FillerPresetHandlerTests
|
||||
{
|
||||
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 GetAllFillerPresetsForApi_Should_Return_All_Presets()
|
||||
{
|
||||
await SeedPreset(1, "Intro");
|
||||
await SeedPreset(2, "Outro");
|
||||
|
||||
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
|
||||
|
||||
List<FillerPresetResponseModel> result =
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
|
||||
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllFillerPresetsForApi_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
|
||||
|
||||
List<FillerPresetResponseModel> result =
|
||||
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedPreset(int id, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FillerPresets.Add(new FillerPreset
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
FillerKind = FillerKind.PreRoll,
|
||||
FillerMode = FillerMode.Duration,
|
||||
CollectionType = CollectionType.Collection
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Graphics;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementHandlerTests
|
||||
{
|
||||
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 GetAllGraphicsElementsForApi_Should_Return_All_Elements()
|
||||
{
|
||||
await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark");
|
||||
await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty);
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.Select(e => e.Id).ShouldBe([1, 2]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed()
|
||||
{
|
||||
// unnamed element's projected Name equals its FileName -> should sort after named elements
|
||||
await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty);
|
||||
await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name");
|
||||
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result[0].Id.ShouldBe(2);
|
||||
result[1].Id.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
|
||||
|
||||
List<GraphicsElementResponseModel> result =
|
||||
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.GraphicsElements.Add(new GraphicsElement
|
||||
{
|
||||
Id = id,
|
||||
Path = path,
|
||||
Name = name,
|
||||
Kind = kind
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using ErsatzTV.Core.Health;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Health;
|
||||
|
||||
[TestFixture]
|
||||
public class GetAllHealthCheckResultsForApiHandlerTests
|
||||
{
|
||||
private IHealthCheckService _healthCheckService = null!;
|
||||
private GetAllHealthCheckResultsForApiHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_healthCheckService = Substitute.For<IHealthCheckService>();
|
||||
_handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Map_Status_Codes_To_Lowercase_Strings()
|
||||
{
|
||||
var results = new List<HealthCheckResult>
|
||||
{
|
||||
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
|
||||
new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option<HealthCheckLink>.None),
|
||||
new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option<HealthCheckLink>.None),
|
||||
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response.Count.ShouldBe(4);
|
||||
response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Filter_Out_NotApplicable_Results()
|
||||
{
|
||||
var results = new List<HealthCheckResult>
|
||||
{
|
||||
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
|
||||
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response.Count.ShouldBe(1);
|
||||
response[0].Title.ShouldBe("Pass Check");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Include_Link_When_Present()
|
||||
{
|
||||
var results = new List<HealthCheckResult>
|
||||
{
|
||||
new(
|
||||
"Linked Check",
|
||||
HealthCheckStatus.Warning,
|
||||
"detail message",
|
||||
"brief",
|
||||
Option<HealthCheckLink>.Some(new HealthCheckLink("https://example.com/docs")))
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response[0].Link.ShouldBe("https://example.com/docs");
|
||||
response[0].Detail.ShouldBe("detail message");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Have_Null_Link_When_Absent()
|
||||
{
|
||||
var results = new List<HealthCheckResult>
|
||||
{
|
||||
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
|
||||
};
|
||||
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response[0].Link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_Empty_List_On_Cancellation()
|
||||
{
|
||||
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>())
|
||||
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
|
||||
|
||||
List<HealthCheckResponseModel> response =
|
||||
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
|
||||
|
||||
response.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using MediatR;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class GetLibraryScanStatusHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Active_Scans()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
var scannerProxyService = new ScannerProxyService(mediator);
|
||||
Guid scanId = scannerProxyService.StartScan(42)
|
||||
.Match(
|
||||
Some: id => id,
|
||||
None: () => throw new AssertionException("Expected scan to start"));
|
||||
await scannerProxyService.Progress(scanId, 62.5m);
|
||||
|
||||
var handler = new GetLibraryScanStatusHandler(scannerProxyService);
|
||||
|
||||
List<LibraryScanStatusResponseModel> result =
|
||||
await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
|
||||
|
||||
result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaSources;
|
||||
|
||||
[TestFixture]
|
||||
public class GetAllMediaSourcesForApiHandlerTests
|
||||
{
|
||||
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_Group_Configured_Libraries_By_Source_With_Item_Counts()
|
||||
{
|
||||
await SeedMediaSources();
|
||||
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
|
||||
|
||||
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(4);
|
||||
|
||||
result[0].Kind.ShouldBe("Local");
|
||||
result[0].Name.ShouldBe("Local");
|
||||
result[0].ConnectionAddress.ShouldBeNull();
|
||||
result[0].Libraries.Single().Name.ShouldBe("Local Movies");
|
||||
result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
result[0].Libraries.Single().ItemCount.ShouldBe(2);
|
||||
|
||||
result[1].Kind.ShouldBe("Emby");
|
||||
result[1].Name.ShouldBe("Emby Server");
|
||||
result[1].ConnectionAddress.ShouldBe("http://emby.local");
|
||||
result[1].Libraries.Single().Name.ShouldBe("Emby Shows");
|
||||
result[1].Libraries.Single().ItemCount.ShouldBe(1);
|
||||
|
||||
result[2].Kind.ShouldBe("Jellyfin");
|
||||
result[2].Name.ShouldBe("Jellyfin Server");
|
||||
result[2].ConnectionAddress.ShouldBe("http://jellyfin.local");
|
||||
result[2].Libraries.ShouldBeEmpty();
|
||||
|
||||
result[3].Kind.ShouldBe("Plex");
|
||||
result[3].Name.ShouldBe("Plex Server");
|
||||
result[3].ConnectionAddress.ShouldBe("http://plex.local");
|
||||
result[3].Libraries.Single().Name.ShouldBe("Plex Movies");
|
||||
result[3].Libraries.Single().ItemCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
|
||||
{
|
||||
await SeedMediaSources();
|
||||
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
|
||||
|
||||
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
|
||||
|
||||
result.SelectMany(s => s.Libraries).Select(l => l.Name)
|
||||
.ShouldNotContain("Empty Local");
|
||||
result.SelectMany(s => s.Libraries).Select(l => l.Name)
|
||||
.ShouldNotContain("Disabled Jellyfin");
|
||||
}
|
||||
|
||||
private async Task SeedMediaSources()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
|
||||
var localSource = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Local Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Paths = [MakePath("/media/movies", 2)]
|
||||
},
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Empty Local",
|
||||
MediaKind = LibraryMediaKind.Shows,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var plexSource = new PlexMediaSource
|
||||
{
|
||||
ServerName = "Plex Server",
|
||||
ProductVersion = "1",
|
||||
Platform = "Linux",
|
||||
PlatformVersion = "1",
|
||||
ClientIdentifier = "plex",
|
||||
Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }],
|
||||
PathReplacements = [],
|
||||
Libraries =
|
||||
[
|
||||
new PlexLibrary
|
||||
{
|
||||
Name = "Plex Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Key = "1",
|
||||
ShouldSyncItems = true,
|
||||
Paths = []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var embySource = new EmbyMediaSource
|
||||
{
|
||||
ServerName = "Emby Server",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [new EmbyConnection { Address = "http://emby.local" }],
|
||||
PathReplacements = [],
|
||||
Libraries =
|
||||
[
|
||||
new EmbyLibrary
|
||||
{
|
||||
Name = "Emby Shows",
|
||||
MediaKind = LibraryMediaKind.Shows,
|
||||
ItemId = "emby-shows",
|
||||
ShouldSyncItems = true,
|
||||
PathInfos = [],
|
||||
Paths = [MakePath("/emby/shows", 1)]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var jellyfinSource = new JellyfinMediaSource
|
||||
{
|
||||
ServerName = "Jellyfin Server",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }],
|
||||
PathReplacements = [],
|
||||
Libraries =
|
||||
[
|
||||
new JellyfinLibrary
|
||||
{
|
||||
Name = "Disabled Jellyfin",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
ItemId = "jellyfin-movies",
|
||||
ShouldSyncItems = false,
|
||||
PathInfos = [],
|
||||
Paths = [MakePath("/jellyfin/movies", 1)]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource);
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
private static LibraryPath MakePath(string path, int mediaItemCount) =>
|
||||
new()
|
||||
{
|
||||
Path = path,
|
||||
LibraryFolders = [],
|
||||
MediaItems = Enumerable.Range(0, mediaItemCount)
|
||||
.Select(_ => new Movie
|
||||
{
|
||||
MovieMetadata = [],
|
||||
MediaVersions = [],
|
||||
Collections = [],
|
||||
CollectionItems = [],
|
||||
TraktListItems = []
|
||||
})
|
||||
.Cast<MediaItem>()
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Watermarks;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkHandlerTests
|
||||
{
|
||||
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 GetAllWatermarksForApi_Should_Return_All_Watermarks()
|
||||
{
|
||||
await SeedWatermark(1, "Bug");
|
||||
await SeedWatermark(2, "Logo");
|
||||
|
||||
var handler = new GetAllWatermarksForApiHandler(_db.Factory);
|
||||
|
||||
List<WatermarkResponseModel> result =
|
||||
await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(new WatermarkResponseModel(1, "Bug"));
|
||||
result.ShouldContain(new WatermarkResponseModel(2, "Logo"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAllWatermarksForApi_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
var handler = new GetAllWatermarksForApiHandler(_db.Factory);
|
||||
|
||||
List<WatermarkResponseModel> result =
|
||||
await handler.Handle(new GetAllWatermarksForApi(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task SeedWatermark(int id, string name)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.ChannelWatermarks.Add(new ChannelWatermark
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Image = "watermark.png",
|
||||
Location = WatermarkLocation.BottomRight,
|
||||
Size = WatermarkSize.Scaled,
|
||||
WidthPercent = 10,
|
||||
HorizontalMarginPercent = 2,
|
||||
VerticalMarginPercent = 2,
|
||||
FrequencyMinutes = 15,
|
||||
DurationSeconds = 30,
|
||||
Opacity = 100
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -6,9 +6,9 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
using MediatR;
|
||||
@@ -24,15 +24,34 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
public class ChannelControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||
private ChannelController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
_controller = new ChannelController(_workerChannel.Writer, _mediator);
|
||||
ChannelWriter<IBackgroundServiceRequest> writer =
|
||||
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
_controller = new ChannelController(writer, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Return_200_With_Channel_State()
|
||||
{
|
||||
var state = new ChannelStateResponseModel(
|
||||
7,
|
||||
"7.1",
|
||||
true,
|
||||
new ChannelNowPlayingResponseModel(
|
||||
"Retro Cartoons",
|
||||
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
|
||||
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([state]);
|
||||
|
||||
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new List<ChannelStateResponseModel> { state });
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -126,6 +145,95 @@ public class ChannelControllerTests
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateChannelNumbers>(
|
||||
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkMoveChannelsToGroup>(
|
||||
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([99], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([1, 2]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([99]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
@@ -158,7 +266,7 @@ public class ChannelControllerTests
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
|
||||
IActionResult result = await _controller.ResetPlayout("404");
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
@@ -166,53 +274,6 @@ public class ChannelControllerTests
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
|
||||
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
|
||||
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
|
||||
public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind(
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
PlayoutBuildMode expectedMode)
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
|
||||
buildPlayout.PlayoutId.ShouldBe(9);
|
||||
buildPlayout.Mode.ShouldBe(expectedMode);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.Some(9));
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
"Channel",
|
||||
"5",
|
||||
ChannelPlayoutMode.Continuous,
|
||||
"Schedule",
|
||||
string.Empty,
|
||||
null,
|
||||
null);
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
new(
|
||||
id,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class FillerPresetControllerTests
|
||||
{
|
||||
private FillerPresetController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new FillerPresetController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(FillerPresetController).GetMethod(nameof(FillerPresetController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/filler-presets");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_FillerPresets()
|
||||
{
|
||||
List<FillerPresetResponseModel> models =
|
||||
[
|
||||
new FillerPresetResponseModel(1, "Intro"),
|
||||
new FillerPresetResponseModel(2, "Outro")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class GraphicsElementControllerTests
|
||||
{
|
||||
private GraphicsElementController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new GraphicsElementController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(GraphicsElementController).GetMethod(nameof(GraphicsElementController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/graphics-elements");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_GraphicsElements()
|
||||
{
|
||||
List<GraphicsElementResponseModel> models =
|
||||
[
|
||||
new GraphicsElementResponseModel(1, "Lower Third (lower-third.png)"),
|
||||
new GraphicsElementResponseModel(2, "bug.png")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class HealthControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private HealthController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new HealthController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(HealthController).GetMethod(nameof(HealthController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/health");
|
||||
attribute.Name.ShouldBe("GetHealthChecks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<HealthCheckResponseModel>
|
||||
{
|
||||
new("Check One", "pass", "all good", null),
|
||||
new("Check Two", "fail", "broken", "https://example.com")
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllHealthCheckResultsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<HealthCheckResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LibrariesControllerTests
|
||||
{
|
||||
private LibrariesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScanStatus_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus))
|
||||
?? throw new AssertionException("Missing action GetScanStatus");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/libraries/scan-status");
|
||||
attribute.Name.ShouldBe("GetLibraryScanStatus");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetScanStatus_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<LibraryScanStatusResponseModel>
|
||||
{
|
||||
new(1, 42.5m),
|
||||
new(2, 99m)
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetLibraryScanStatus>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<LibraryScanStatusResponseModel> result = await _controller.GetScanStatus(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class MediaSourcesControllerTests
|
||||
{
|
||||
private MediaSourcesController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new MediaSourcesController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/media-sources");
|
||||
attribute.Name.ShouldBe("GetMediaSources");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Results_From_Mediator()
|
||||
{
|
||||
var expected = new List<MediaSourceResponseModel>
|
||||
{
|
||||
new(
|
||||
1,
|
||||
"Local",
|
||||
"Local",
|
||||
null,
|
||||
[new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)])
|
||||
};
|
||||
|
||||
_mediator.Send(Arg.Any<GetAllMediaSourcesForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(expected);
|
||||
|
||||
List<MediaSourceResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,82 @@ public class OpenApiErrorResponseContractTests
|
||||
.ShouldContain("One");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Static_OpenApi_Should_Document_Channel_List_Management_Fields()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
|
||||
JsonElement schema = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
schema.TryGetProperty("group", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("isEnabled", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("showInEpg", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("sortNumber", out _).ShouldBeTrue();
|
||||
schema.TryGetProperty("categories", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Static_OpenApi_Should_Document_Channel_State_Response()
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
|
||||
JsonElement getState = document.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty("/api/channels/state")
|
||||
.GetProperty("get");
|
||||
|
||||
JsonElement schema = getState
|
||||
.GetProperty("responses")
|
||||
.GetProperty("200")
|
||||
.GetProperty("content")
|
||||
.GetProperty("application/json")
|
||||
.GetProperty("schema");
|
||||
|
||||
schema.GetProperty("type").GetString().ShouldBe("array");
|
||||
schema.GetProperty("items").GetProperty("$ref").GetString()
|
||||
.ShouldBe("#/components/schemas/ChannelStateResponseModel");
|
||||
|
||||
JsonElement stateProperties = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelStateResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
|
||||
stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue();
|
||||
|
||||
JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf");
|
||||
nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2);
|
||||
nowPlayingOneOf.EnumerateArray()
|
||||
.Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null")
|
||||
.ShouldBeTrue();
|
||||
nowPlayingOneOf.EnumerateArray()
|
||||
.Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) &&
|
||||
schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel")
|
||||
.ShouldBeTrue();
|
||||
|
||||
JsonElement nowPlayingProperties = document.RootElement
|
||||
.GetProperty("components")
|
||||
.GetProperty("schemas")
|
||||
.GetProperty("ChannelNowPlayingResponseModel")
|
||||
.GetProperty("properties");
|
||||
|
||||
nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue();
|
||||
nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue();
|
||||
nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue();
|
||||
|
||||
startUtc.GetProperty("type").GetString().ShouldBe("string");
|
||||
startUtc.GetProperty("format").GetString().ShouldBe("date-time");
|
||||
finishUtc.GetProperty("type").GetString().ShouldBe("string");
|
||||
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
|
||||
}
|
||||
|
||||
[TestCase("/api/channels/{id}", "get", "404")]
|
||||
[TestCase("/api/channels", "post", "404")]
|
||||
[TestCase("/api/channels", "post", "422")]
|
||||
@@ -30,6 +106,12 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
@@ -68,7 +150,6 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/playouts", "post", "422")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "404")]
|
||||
[TestCase("/api/playouts/{id}", "delete", "422")]
|
||||
[TestCase("/api/playouts/{id}/items", "get", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "404")]
|
||||
[TestCase("/api/ffmpeg/profiles", "post", "401")]
|
||||
|
||||
@@ -5,7 +5,6 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -35,12 +34,8 @@ public class PlayoutControllerTests
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/playouts/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/playouts/{id:int}/items");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/playouts/warnings/count");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/playouts");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/playouts/reset-all");
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
|
||||
}
|
||||
|
||||
@@ -167,130 +162,6 @@ public class PlayoutControllerTests
|
||||
problem.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Project_Paged_List_With_BuildStatus()
|
||||
{
|
||||
var buildStatus = new PlayoutBuildStatus
|
||||
{
|
||||
LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero),
|
||||
Success = false,
|
||||
Message = "boom"
|
||||
};
|
||||
PlayoutNameViewModel vm = MakePlayout(9) with
|
||||
{
|
||||
BuildStatus = buildStatus,
|
||||
DbDailyRebuildTime = TimeSpan.FromHours(4)
|
||||
};
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None);
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
PlayoutListItemResponseModel item = result.Page.Single();
|
||||
item.Id.ShouldBe(9);
|
||||
item.ChannelNumber.ShouldBe("101");
|
||||
item.ChannelName.ShouldBe("Channel");
|
||||
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
|
||||
item.ScheduleName.ShouldBe("Schedule");
|
||||
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
|
||||
item.BuildStatus.ShouldNotBeNull();
|
||||
item.BuildStatus.Success.ShouldBeFalse();
|
||||
item.BuildStatus.Message.ShouldBe("boom");
|
||||
item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetPagedPlayouts>(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent()
|
||||
{
|
||||
PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null };
|
||||
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
||||
|
||||
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
||||
|
||||
result.Page.Single().BuildStatus.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
||||
|
||||
var item = new PlayoutItemViewModel(
|
||||
"Movie",
|
||||
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
"1:00:00",
|
||||
string.Empty,
|
||||
Some(FillerKind.MidRoll));
|
||||
var gap = new PlayoutItemViewModel(
|
||||
"UNSCHEDULED",
|
||||
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
|
||||
"30:00",
|
||||
string.Empty,
|
||||
Option<FillerKind>.None);
|
||||
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new PagedPlayoutItemsViewModel(2, [item, gap]));
|
||||
|
||||
IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None);
|
||||
|
||||
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
||||
result.TotalCount.ShouldBe(2);
|
||||
result.Page[0].Title.ShouldBe("Movie");
|
||||
result.Page[0].Duration.ShouldBe("1:00:00");
|
||||
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
|
||||
result.Page[1].Title.ShouldBe("UNSCHEDULED");
|
||||
result.Page[1].FillerKind.ShouldBeNull();
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetFuturePlayoutItemsById>(q =>
|
||||
q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlayoutNameViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problem.Status.ShouldBe(404);
|
||||
problem.Title.ShouldBe("Resource not found");
|
||||
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetWarningsCount_Should_Return_Count()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutWarningsCount>(), Arg.Any<CancellationToken>())
|
||||
.Returns(7);
|
||||
|
||||
int result = await _controller.GetWarningsCount(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetAll_Should_Return_202_And_Send_Command()
|
||||
{
|
||||
IActionResult result = await _controller.ResetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id) =>
|
||||
new(
|
||||
id,
|
||||
@@ -312,13 +183,7 @@ public class PlayoutControllerTests
|
||||
vm.PlayoutMode,
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime,
|
||||
vm.BuildStatus is null
|
||||
? null
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
vm.BuildStatus.LastBuild,
|
||||
vm.BuildStatus.Success,
|
||||
vm.BuildStatus.Message));
|
||||
vm.DbDailyRebuildTime);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkControllerTests
|
||||
{
|
||||
private WatermarkController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new WatermarkController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Route()
|
||||
{
|
||||
MethodInfo action = typeof(WatermarkController).GetMethod(nameof(WatermarkController.GetAll))
|
||||
?? throw new AssertionException("Missing action GetAll");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain("GET");
|
||||
attribute.Template.ShouldBe("/api/watermarks");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Watermarks()
|
||||
{
|
||||
List<WatermarkResponseModel> models =
|
||||
[
|
||||
new WatermarkResponseModel(1, "Corner Logo"),
|
||||
new WatermarkResponseModel(2, "Ticker")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(models);
|
||||
|
||||
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(models);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Empty_List_When_None_Exist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllWatermarksForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns([]);
|
||||
|
||||
List<WatermarkResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class StartupSpaHostingTests
|
||||
{
|
||||
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path()
|
||||
{
|
||||
StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")");
|
||||
StartupSource.ShouldContain("MapWhen(");
|
||||
StartupSource.ShouldContain("SpaStaticFileRoot");
|
||||
StartupSource.ShouldContain("RequestPath = \"/app\"");
|
||||
StartupSource.ShouldContain("appIndexFile");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
|
||||
{
|
||||
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
|
||||
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
|
||||
}
|
||||
|
||||
private static string FindStartupPath()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
|
||||
protected async Task SeedChannel(
|
||||
int id,
|
||||
string number,
|
||||
string name = "Test",
|
||||
int ffmpegProfileId = 1,
|
||||
string group = "ErsatzTV")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Channels.Add(
|
||||
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Group = group,
|
||||
Categories = string.Empty,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamSelector = string.Empty,
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
@@ -22,6 +21,13 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/channels/state")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get channel runtime state")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
@@ -88,45 +94,68 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/renumber")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Renumber channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkRenumber(
|
||||
[Required] [FromBody] BulkRenumberChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/group")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Move channels to a group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkMoveToGroup(
|
||||
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/delete")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkDelete(
|
||||
[Required] [FromBody] BulkDeleteChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointDescription(
|
||||
"When mode is omitted, classic playouts use Refresh (rebuild while maintaining collection " +
|
||||
"progress) and all other playout kinds use Reset (rebuild from scratch), matching the Blazor UI. " +
|
||||
"Pass mode to force a specific PlayoutBuildMode.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ResetPlayout(
|
||||
string channelNumber,
|
||||
[FromQuery] PlayoutBuildMode? mode,
|
||||
CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> ResetPlayout(string channelNumber)
|
||||
{
|
||||
Option<int> maybePlayoutId =
|
||||
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
|
||||
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
|
||||
await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset));
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// Match Blazor's Playouts.razor reset semantics: classic playouts refresh (preserve progress),
|
||||
// every other kind resets from scratch.
|
||||
private async Task<PlayoutBuildMode> DefaultResetMode(int playoutId, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout =
|
||||
await mediator.Send(new GetPlayoutById(playoutId), cancellationToken);
|
||||
return maybePlayout.Match(
|
||||
Some: vm => vm.ScheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
|
||||
_ => PlayoutBuildMode.Reset
|
||||
},
|
||||
None: () => PlayoutBuildMode.Reset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Application.Filler;
|
||||
using ErsatzTV.Core.Api.Filler;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class FillerPresetController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
|
||||
[Tags("Filler Presets")]
|
||||
[EndpointSummary("Get all filler presets")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Core.Api.Graphics;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class GraphicsElementController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
|
||||
[Tags("Graphics Elements")]
|
||||
[EndpointSummary("Get all graphics elements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using ErsatzTV.Application.Health;
|
||||
using ErsatzTV.Core.Api.Health;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class HealthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/health", Name = "GetHealthChecks")]
|
||||
[Tags("Health")]
|
||||
[EndpointSummary("Get health check results")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<HealthCheckResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<HealthCheckResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken);
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Api.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
|
||||
{
|
||||
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get active library scan status")]
|
||||
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Scan library")]
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MediaSourcesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
|
||||
[Tags("Media Sources")]
|
||||
[EndpointSummary("Get all media sources with their libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
|
||||
}
|
||||
@@ -3,8 +3,6 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -15,32 +13,6 @@ namespace ErsatzTV.Controllers.Api;
|
||||
[ApiController]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/playouts", Name = "GetPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("List playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<PagedPlayoutsResponseModel> GetAll(
|
||||
[FromQuery] string query = "",
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PagedPlayoutsViewModel result =
|
||||
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
|
||||
return new PagedPlayoutsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToListItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Count playouts with a failed build")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
|
||||
public async Task<int> GetWarningsCount(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get a playout by id")]
|
||||
@@ -53,34 +25,6 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
return result.Map(ToResponse).ToGetResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/playouts/{id:int}/items", Name = "GetPlayoutItems")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Get upcoming items (and unscheduled gaps) for a playout")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PagedPlayoutItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
int id,
|
||||
[FromQuery] bool showFiller = false,
|
||||
[FromQuery] int pageNum = 0,
|
||||
[FromQuery] int pageSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
|
||||
if (maybePlayout.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
PagedPlayoutItemsViewModel result = await mediator.Send(
|
||||
new GetFuturePlayoutItemsById(id, showFiller, pageNum, pageSize),
|
||||
cancellationToken);
|
||||
return new OkObjectResult(
|
||||
new PagedPlayoutItemsResponseModel(
|
||||
result.TotalCount,
|
||||
result.Page.Map(ToItemResponse).ToList()));
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Create a classic playout")]
|
||||
@@ -105,17 +49,6 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/playouts/reset-all", Name = "ResetAllPlayouts")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Reset all playouts")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playouts/{id:int}")]
|
||||
[Tags("Playouts")]
|
||||
[EndpointSummary("Delete a playout")]
|
||||
@@ -138,32 +71,5 @@ public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
vm.PlayoutMode,
|
||||
vm.ScheduleName,
|
||||
vm.ScheduleFile,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
|
||||
new(
|
||||
vm.PlayoutId,
|
||||
vm.ChannelNumber,
|
||||
vm.ChannelName,
|
||||
vm.ScheduleKind,
|
||||
vm.ScheduleName,
|
||||
vm.DbDailyRebuildTime,
|
||||
ToBuildStatus(vm.BuildStatus));
|
||||
|
||||
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
|
||||
buildStatus is null
|
||||
? null
|
||||
: new PlayoutBuildStatusResponseModel(
|
||||
buildStatus.LastBuild,
|
||||
buildStatus.Success,
|
||||
buildStatus.Message);
|
||||
|
||||
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
|
||||
new(
|
||||
vm.Title,
|
||||
vm.Start,
|
||||
vm.Finish,
|
||||
vm.Duration,
|
||||
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
|
||||
vm.DbDailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record BulkRenumberChannelRequest(int Id, string Number);
|
||||
|
||||
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
|
||||
{
|
||||
public UpdateChannelNumbers ToCommand() =>
|
||||
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
|
||||
}
|
||||
|
||||
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
|
||||
{
|
||||
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
|
||||
}
|
||||
|
||||
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
|
||||
{
|
||||
public BulkDeleteChannels ToCommand() => new(ChannelIds);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using ErsatzTV.Application.Watermarks;
|
||||
using ErsatzTV.Core.Api.Watermarks;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class WatermarkController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/watermarks", Name = "GetWatermarks")]
|
||||
[Tags("Watermarks")]
|
||||
[EndpointSummary("Get all watermarks")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<WatermarkResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<WatermarkResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllWatermarksForApi(), cancellationToken);
|
||||
}
|
||||
+46
-1
@@ -681,7 +681,42 @@ public class Startup
|
||||
});
|
||||
|
||||
app.MapWhen(
|
||||
ctx => !IsIptvPath(ctx.Request.Path),
|
||||
ctx => ctx.Request.Path.StartsWithSegments("/app"),
|
||||
spa =>
|
||||
{
|
||||
string spaStaticFileRoot = SpaStaticFileRoot();
|
||||
IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot)
|
||||
? new PhysicalFileProvider(spaStaticFileRoot)
|
||||
: new NullFileProvider();
|
||||
|
||||
spa.UseStaticFiles(
|
||||
new StaticFileOptions
|
||||
{
|
||||
FileProvider = spaFileProvider,
|
||||
RequestPath = "/app"
|
||||
});
|
||||
|
||||
spa.Run(
|
||||
async context =>
|
||||
{
|
||||
Microsoft.Extensions.FileProviders.IFileInfo appIndexFile =
|
||||
spaFileProvider.GetFileInfo("index.html");
|
||||
if (!appIndexFile.Exists)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength = appIndexFile.Length;
|
||||
|
||||
await using Stream stream = appIndexFile.CreateReadStream();
|
||||
await stream.CopyToAsync(context.Response.Body);
|
||||
});
|
||||
});
|
||||
|
||||
app.MapWhen(
|
||||
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
|
||||
blazor =>
|
||||
{
|
||||
blazor.UseRouting();
|
||||
@@ -736,6 +771,16 @@ public class Startup
|
||||
path.StartsWithSegments("/lineup.json") ||
|
||||
path.StartsWithSegments("/lineup_status.json");
|
||||
}
|
||||
|
||||
bool IsSpaPath(PathString path) => path.StartsWithSegments("/app");
|
||||
|
||||
string SpaStaticFileRoot()
|
||||
{
|
||||
string webRootPath = CurrentEnvironment.WebRootPath ??
|
||||
Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot");
|
||||
|
||||
return Path.Combine(webRootPath, "app");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CustomServices(IServiceCollection services)
|
||||
|
||||
+496
-866
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
|
||||
</x-dc>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: chicorytv-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for ChicoryTV, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Read the README.md file within this skill, and explore the other available files.
|
||||
If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.
|
||||
|
||||
Quick map:
|
||||
- `readme.md` — brand context, content & visual foundations, iconography, file index.
|
||||
- `styles.css` — the single entry point; link it and use the CSS custom properties.
|
||||
- `tokens/` — color, type, spacing, radius/elevation/motion tokens + font loading.
|
||||
- `components/` — React primitives (forms, data-display, feedback, navigation).
|
||||
- `templates/chicorytv-admin/` — full admin shell to start from / copy screens out of (incl. the library-to-lineup Channel Builder).
|
||||
- `assets/` — chicory-flower mark, wordmark, app icon.
|
||||
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"plugins": [
|
||||
"react",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
"react/forbid-elements": [
|
||||
"warn",
|
||||
{
|
||||
"forbid": []
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"components/data-display/**",
|
||||
"components/feedback/**",
|
||||
"components/forms/**",
|
||||
"components/navigation/**",
|
||||
"design_handoff_channel_builder/**"
|
||||
],
|
||||
"message": "Import design-system components from 'index.js', not component internals."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
|
||||
"message": "Raw hex color — use a design-system color token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/\\b\\d+px\\b/]",
|
||||
"message": "Raw px value — use a design-system spacing token via var()."
|
||||
},
|
||||
{
|
||||
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono))/i]",
|
||||
"message": "Font not provided by the design system. Available: Geist, Geist Mono."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|solid|dot|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Badge> doesn't accept that prop. Declared props: children, tone, solid, dot, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|ok|warn|error)$/]",
|
||||
"message": "<Badge> tone must be one of 'neutral' | 'accent' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|startIcon|endIcon|disabled|loading|fullWidth|type|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Button> doesn't accept that prop. Declared props: children, variant, size, startIcon, endIcon, disabled, loading, fullWidth, type, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]",
|
||||
"message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Button> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
|
||||
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|subtitle|actions|children|padded|inset|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Card> doesn't accept that prop. Declared props: title, subtitle, actions, children, padded, inset, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ChannelLogo'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|src|size|radius|style|key|ref|className|style|children)$/]",
|
||||
"message": "<ChannelLogo> doesn't accept that prop. Declared props: name, src, size, radius, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|indeterminate|disabled|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Checkbox> doesn't accept that prop. Declared props: checked, onChange, label, indeterminate, disabled, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|variant|active|disabled|title|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<IconButton> doesn't accept that prop. Declared props: children, size, variant, active, disabled, title, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<IconButton> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:ghost|solid)$/]",
|
||||
"message": "<IconButton> variant must be one of 'ghost' | 'solid'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|onChange|placeholder|leadingIcon|trailing|error|disabled|size|type|fullWidth|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Input> doesn't accept that prop. Declared props: label, value, onChange, placeholder, leadingIcon, trailing, error, disabled, size, type, fullWidth, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Input> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|label|active|badge|badgeTone|href|onClick|style|key|ref|className|style|children)$/]",
|
||||
"message": "<NavItem> doesn't accept that prop. Declared props: icon, label, active, badge, badgeTone, href, onClick, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute[name.name='badgeTone'] > Literal[value!=/^(?:warn|error|accent)$/]",
|
||||
"message": "<NavItem> badgeTone must be one of 'warn' | 'error' | 'accent'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|tone|height|showLabel|style|key|ref|className|style|children)$/]",
|
||||
"message": "<ProgressBar> doesn't accept that prop. Declared props: value, tone, height, showLabel, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|ok|warn|error)$/]",
|
||||
"message": "<ProgressBar> tone must be one of 'accent' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='SelectOption'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|key|ref|className|style|children)$/]",
|
||||
"message": "<SelectOption> doesn't accept that prop. Declared props: value, label."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute > JSXIdentifier[name!=/^(?:size|tone|stroke|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Spinner> doesn't accept that prop. Declared props: size, tone, stroke, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|muted|ok)$/]",
|
||||
"message": "<Spinner> tone must be one of 'accent' | 'muted' | 'ok'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|unit|delta|deltaTone|icon|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Stat> doesn't accept that prop. Declared props: label, value, unit, delta, deltaTone, icon, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute[name.name='deltaTone'] > Literal[value!=/^(?:up|down|neutral)$/]",
|
||||
"message": "<Stat> deltaTone must be one of 'up' | 'down' | 'neutral'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute > JSXIdentifier[name!=/^(?:status|pulse|size|label|style|key|ref|className|style|children)$/]",
|
||||
"message": "<StatusDot> doesn't accept that prop. Declared props: status, pulse, size, label, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute[name.name='status'] > Literal[value!=/^(?:live|ok|warn|error|idle)$/]",
|
||||
"message": "<StatusDot> status must be one of 'live' | 'ok' | 'warn' | 'error' | 'idle'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|disabled|size|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Switch> doesn't accept that prop. Declared props: checked, onChange, label, disabled, size, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
|
||||
"message": "<Switch> size must be one of 'sm' | 'md'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='TabItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|icon|count|key|ref|className|style|children)$/]",
|
||||
"message": "<TabItem> doesn't accept that prop. Declared props: value, label, icon, count."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onRemove|icon|tone|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tag> doesn't accept that prop. Declared props: children, onRemove, icon, tone, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent)$/]",
|
||||
"message": "<Tag> tone must be one of 'neutral' | 'accent'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|title|message|onClose|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Toast> doesn't accept that prop. Declared props: tone, title, message, onClose, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:info|ok|warn|error)$/]",
|
||||
"message": "<Toast> tone must be one of 'info' | 'ok' | 'warn' | 'error'."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|placement|children|style|key|ref|className|style|children)$/]",
|
||||
"message": "<Tooltip> doesn't accept that prop. Declared props: label, placement, children, style."
|
||||
},
|
||||
{
|
||||
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute[name.name='placement'] > Literal[value!=/^(?:top|bottom|left|right)$/]",
|
||||
"message": "<Tooltip> placement must be one of 'top' | 'bottom' | 'left' | 'right'."
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/index.js"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-omelette": {
|
||||
"components": {
|
||||
"Badge": {
|
||||
"replaces": []
|
||||
},
|
||||
"Button": {
|
||||
"replaces": []
|
||||
},
|
||||
"Card": {
|
||||
"replaces": []
|
||||
},
|
||||
"ChannelLogo": {
|
||||
"replaces": []
|
||||
},
|
||||
"Checkbox": {
|
||||
"replaces": []
|
||||
},
|
||||
"IconButton": {
|
||||
"replaces": []
|
||||
},
|
||||
"Input": {
|
||||
"replaces": []
|
||||
},
|
||||
"NavItem": {
|
||||
"replaces": []
|
||||
},
|
||||
"ProgressBar": {
|
||||
"replaces": []
|
||||
},
|
||||
"SelectOption": {
|
||||
"replaces": []
|
||||
},
|
||||
"Spinner": {
|
||||
"replaces": []
|
||||
},
|
||||
"Stat": {
|
||||
"replaces": []
|
||||
},
|
||||
"StatusDot": {
|
||||
"replaces": []
|
||||
},
|
||||
"Switch": {
|
||||
"replaces": []
|
||||
},
|
||||
"TabItem": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tag": {
|
||||
"replaces": []
|
||||
},
|
||||
"Toast": {
|
||||
"replaces": []
|
||||
},
|
||||
"Tooltip": {
|
||||
"replaces": []
|
||||
}
|
||||
},
|
||||
"tokens": [
|
||||
"--action-primary",
|
||||
"--action-primary-hover",
|
||||
"--action-primary-press",
|
||||
"--border-control",
|
||||
"--border-hairline",
|
||||
"--control-h",
|
||||
"--control-h-sm",
|
||||
"--ctv-accent",
|
||||
"--ctv-accent-hover",
|
||||
"--ctv-accent-press",
|
||||
"--ctv-accent-ring",
|
||||
"--ctv-accent-soft",
|
||||
"--ctv-bg",
|
||||
"--ctv-bg-sunken",
|
||||
"--ctv-border",
|
||||
"--ctv-border-strong",
|
||||
"--ctv-error",
|
||||
"--ctv-error-soft",
|
||||
"--ctv-live",
|
||||
"--ctv-live-soft",
|
||||
"--ctv-ok",
|
||||
"--ctv-ok-soft",
|
||||
"--ctv-secondary",
|
||||
"--ctv-secondary-soft",
|
||||
"--ctv-surface",
|
||||
"--ctv-surface-2",
|
||||
"--ctv-surface-3",
|
||||
"--ctv-text",
|
||||
"--ctv-text-faint",
|
||||
"--ctv-text-muted",
|
||||
"--ctv-warn",
|
||||
"--ctv-warn-soft",
|
||||
"--dur-base",
|
||||
"--dur-fast",
|
||||
"--dur-slow",
|
||||
"--ease-out",
|
||||
"--ease-standard",
|
||||
"--focus-ring",
|
||||
"--font-mono",
|
||||
"--font-sans",
|
||||
"--gap-inline",
|
||||
"--leading-normal",
|
||||
"--leading-snug",
|
||||
"--leading-tight",
|
||||
"--numeric-feature",
|
||||
"--pad-card",
|
||||
"--pad-cell-x",
|
||||
"--pad-cell-y",
|
||||
"--pad-control-x",
|
||||
"--pad-control-y",
|
||||
"--pad-page",
|
||||
"--radius-lg",
|
||||
"--radius-md",
|
||||
"--radius-pill",
|
||||
"--radius-sm",
|
||||
"--radius-xs",
|
||||
"--ring-focus",
|
||||
"--row-h",
|
||||
"--shadow-lg",
|
||||
"--shadow-md",
|
||||
"--shadow-none",
|
||||
"--shadow-pop",
|
||||
"--shadow-sm",
|
||||
"--sidebar-w",
|
||||
"--space-0",
|
||||
"--space-1",
|
||||
"--space-10",
|
||||
"--space-11",
|
||||
"--space-12",
|
||||
"--space-2",
|
||||
"--space-3",
|
||||
"--space-4",
|
||||
"--space-5",
|
||||
"--space-6",
|
||||
"--space-7",
|
||||
"--space-8",
|
||||
"--space-9",
|
||||
"--status-error",
|
||||
"--status-live",
|
||||
"--status-ok",
|
||||
"--status-warn",
|
||||
"--surface-app",
|
||||
"--surface-card",
|
||||
"--surface-raised",
|
||||
"--surface-selected",
|
||||
"--text-2xl",
|
||||
"--text-2xs",
|
||||
"--text-3xl",
|
||||
"--text-disabled",
|
||||
"--text-lg",
|
||||
"--text-md",
|
||||
"--text-on-accent",
|
||||
"--text-primary",
|
||||
"--text-secondary",
|
||||
"--text-sm",
|
||||
"--text-xl",
|
||||
"--text-xs",
|
||||
"--topbar-h",
|
||||
"--tracking-caps",
|
||||
"--tracking-normal",
|
||||
"--tracking-tight",
|
||||
"--tracking-wide",
|
||||
"--weight-bold",
|
||||
"--weight-medium",
|
||||
"--weight-normal",
|
||||
"--weight-semibold"
|
||||
],
|
||||
"tokenKinds": {
|
||||
"--ctv-bg": "color",
|
||||
"--ctv-bg-sunken": "color",
|
||||
"--ctv-surface": "color",
|
||||
"--ctv-surface-2": "color",
|
||||
"--ctv-surface-3": "color",
|
||||
"--ctv-border": "color",
|
||||
"--ctv-border-strong": "color",
|
||||
"--ctv-text": "font",
|
||||
"--ctv-text-muted": "font",
|
||||
"--ctv-text-faint": "font",
|
||||
"--ctv-accent": "color",
|
||||
"--ctv-accent-hover": "color",
|
||||
"--ctv-accent-press": "color",
|
||||
"--ctv-accent-soft": "color",
|
||||
"--ctv-accent-ring": "color",
|
||||
"--ctv-secondary": "color",
|
||||
"--ctv-secondary-soft": "color",
|
||||
"--ctv-ok": "color",
|
||||
"--ctv-ok-soft": "color",
|
||||
"--ctv-warn": "color",
|
||||
"--ctv-warn-soft": "color",
|
||||
"--ctv-error": "color",
|
||||
"--ctv-error-soft": "color",
|
||||
"--ctv-live": "color",
|
||||
"--ctv-live-soft": "color",
|
||||
"--surface-app": "color",
|
||||
"--surface-card": "color",
|
||||
"--surface-raised": "color",
|
||||
"--surface-selected": "color",
|
||||
"--border-hairline": "color",
|
||||
"--border-control": "color",
|
||||
"--text-primary": "font",
|
||||
"--text-secondary": "font",
|
||||
"--text-disabled": "font",
|
||||
"--text-on-accent": "font",
|
||||
"--action-primary": "color",
|
||||
"--action-primary-hover": "color",
|
||||
"--action-primary-press": "color",
|
||||
"--focus-ring": "color",
|
||||
"--status-ok": "color",
|
||||
"--status-warn": "color",
|
||||
"--status-error": "color",
|
||||
"--status-live": "color",
|
||||
"--font-sans": "font",
|
||||
"--font-mono": "font",
|
||||
"--text-2xs": "font",
|
||||
"--text-xs": "font",
|
||||
"--text-sm": "font",
|
||||
"--text-md": "font",
|
||||
"--text-lg": "font",
|
||||
"--text-xl": "font",
|
||||
"--text-2xl": "font",
|
||||
"--text-3xl": "font",
|
||||
"--weight-normal": "font",
|
||||
"--weight-medium": "font",
|
||||
"--weight-semibold": "font",
|
||||
"--weight-bold": "font",
|
||||
"--leading-tight": "font",
|
||||
"--leading-snug": "font",
|
||||
"--leading-normal": "font",
|
||||
"--tracking-tight": "font",
|
||||
"--tracking-normal": "font",
|
||||
"--tracking-wide": "font",
|
||||
"--tracking-caps": "font",
|
||||
"--numeric-feature": "other",
|
||||
"--space-0": "spacing",
|
||||
"--space-1": "spacing",
|
||||
"--space-2": "spacing",
|
||||
"--space-3": "spacing",
|
||||
"--space-4": "spacing",
|
||||
"--space-5": "spacing",
|
||||
"--space-6": "spacing",
|
||||
"--space-7": "spacing",
|
||||
"--space-8": "spacing",
|
||||
"--space-9": "spacing",
|
||||
"--space-10": "spacing",
|
||||
"--space-11": "spacing",
|
||||
"--space-12": "spacing",
|
||||
"--gap-inline": "spacing",
|
||||
"--pad-control-x": "spacing",
|
||||
"--pad-control-y": "spacing",
|
||||
"--pad-card": "spacing",
|
||||
"--pad-cell-x": "spacing",
|
||||
"--pad-cell-y": "spacing",
|
||||
"--pad-page": "spacing",
|
||||
"--sidebar-w": "spacing",
|
||||
"--topbar-h": "spacing",
|
||||
"--row-h": "spacing",
|
||||
"--control-h": "spacing",
|
||||
"--control-h-sm": "spacing",
|
||||
"--radius-xs": "radius",
|
||||
"--radius-sm": "radius",
|
||||
"--radius-md": "radius",
|
||||
"--radius-lg": "radius",
|
||||
"--radius-pill": "radius",
|
||||
"--shadow-none": "shadow",
|
||||
"--shadow-sm": "shadow",
|
||||
"--shadow-md": "shadow",
|
||||
"--shadow-lg": "shadow",
|
||||
"--shadow-pop": "shadow",
|
||||
"--ring-focus": "color",
|
||||
"--ease-standard": "other",
|
||||
"--ease-out": "other",
|
||||
"--dur-fast": "other",
|
||||
"--dur-base": "other",
|
||||
"--dur-slow": "other"
|
||||
},
|
||||
"fontFamilies": [
|
||||
"Geist",
|
||||
"Geist Mono"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<g transform="translate(32 32)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#0F1115"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#0F1115"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#171A21"></rect>
|
||||
<g transform="translate(32 32) scale(0.82)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#171A21"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#171A21"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 48" width="250" height="48" fill="none">
|
||||
<g transform="translate(24 24) scale(0.62)">
|
||||
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
|
||||
<circle r="8.5" fill="#0F1115"></circle>
|
||||
<circle r="5" fill="#5B7CFA"></circle>
|
||||
<circle r="2" fill="#0F1115"></circle>
|
||||
</g>
|
||||
<text x="52" y="32" font-family="Geist, system-ui, sans-serif" font-size="24" font-weight="600" letter-spacing="-0.5" fill="#E6E9EF">Chicory<tspan fill="#5B7CFA">TV</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface BadgeProps {
|
||||
children?: React.ReactNode;
|
||||
/** Semantic color. `accent` doubles as the live/on-air tone. */
|
||||
tone?: "neutral" | "accent" | "ok" | "warn" | "error";
|
||||
/** Filled instead of tinted-soft. */
|
||||
solid?: boolean;
|
||||
/** Leading status dot. */
|
||||
dot?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact status/label badge — streaming mode, HLS/MPEG-TS, health status.
|
||||
*/
|
||||
export function Badge(props: BadgeProps): JSX.Element;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
|
||||
const tones = {
|
||||
neutral: { fg: "var(--text-secondary)", bg: "var(--ctv-surface-3)", bd: "var(--border-hairline)" },
|
||||
accent: { fg: "var(--ctv-accent)", bg: "var(--ctv-accent-soft)", bd: "rgba(91,124,250,0.35)" },
|
||||
ok: { fg: "var(--status-ok)", bg: "var(--ctv-ok-soft)", bd: "rgba(63,185,132,0.35)" },
|
||||
warn: { fg: "var(--status-warn)", bg: "var(--ctv-warn-soft)", bd: "rgba(224,168,61,0.35)" },
|
||||
error: { fg: "var(--status-error)", bg: "var(--ctv-error-soft)", bd: "rgba(229,72,77,0.35)" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Small status/label badge. Soft (tinted) or solid.
|
||||
*/
|
||||
export function Badge({ children, tone = "neutral", solid = false, dot = false, style = {} }) {
|
||||
const t = tones[tone] || tones.neutral;
|
||||
const solidBg = { neutral: "var(--ctv-surface-3)", accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
height: "20px",
|
||||
padding: dot ? "0 8px 0 6px" : "0 8px",
|
||||
borderRadius: "var(--radius-xs)",
|
||||
font: `var(--weight-medium) var(--text-2xs)/1 var(--font-sans)`,
|
||||
letterSpacing: "0.02em",
|
||||
color: solid ? (tone === "warn" ? "#1A1206" : "#fff") : t.fg,
|
||||
background: solid ? solidBg[tone] : t.bg,
|
||||
border: solid ? "1px solid transparent" : `1px solid ${t.bd}`,
|
||||
whiteSpace: "nowrap",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: solid ? "rgba(255,255,255,0.9)" : t.fg }} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
**Badge** — small status/label pill. Soft (tinted) by default; `solid` for stronger emphasis. Tones map to the status system; `accent` is also the live/on-air tone.
|
||||
|
||||
```jsx
|
||||
<Badge tone="accent" dot>Live</Badge>
|
||||
<Badge tone="ok">Healthy</Badge>
|
||||
<Badge tone="warn">Not normalizing</Badge>
|
||||
<Badge tone="neutral">HLS Segmenter</Badge>
|
||||
<Badge tone="error" solid>Error</Badge>
|
||||
```
|
||||
|
||||
Props: `tone` (neutral·accent·ok·warn·error), `solid`, `dot`.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* @startingPoint section="Layout" subtitle="Card surface with header + actions" viewport="700x260"
|
||||
*/
|
||||
export interface CardProps {
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
/** Right-aligned header actions (buttons, menu). */
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Body padding — turn off for flush tables/lists. */
|
||||
padded?: boolean;
|
||||
/** Sunken well background instead of card surface. */
|
||||
inset?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel surface with optional header row; the base container for dashboard
|
||||
* widgets, forms and detail panes.
|
||||
*/
|
||||
export function Card(props: CardProps): JSX.Element;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Surface container. Optional header (title + actions) and body padding.
|
||||
*/
|
||||
export function Card({ title, subtitle, actions = null, children, padded = true, inset = false, style = {} }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: inset ? "var(--ctv-bg-sunken)" : "var(--surface-card)",
|
||||
border: "1px solid var(--border-hairline)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
overflow: "hidden",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
padding: "13px 16px",
|
||||
borderBottom: "1px solid var(--border-hairline)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
{title && <div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>}
|
||||
{subtitle && <div style={{ marginTop: 3, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
|
||||
</div>
|
||||
{actions && <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: "0 0 auto" }}>{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: padded ? "16px" : 0 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
**Card** — the panel surface behind dashboard widgets, forms and detail panes. Header row is optional; set `padded={false}` when the body is a flush table or list.
|
||||
|
||||
```jsx
|
||||
<Card title="Recent activity" actions={<Button size="sm" variant="ghost">View all</Button>}>
|
||||
…
|
||||
</Card>
|
||||
<Card padded={false}><ChannelTable/></Card>
|
||||
```
|
||||
|
||||
Props: `title`, `subtitle`, `actions`, `padded`, `inset`.
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ChannelLogoProps {
|
||||
/** Channel name — seeds the generated initials + color when no image. */
|
||||
name?: string;
|
||||
/** Logo image URL; falls back to a generated chip when absent. */
|
||||
src?: string | null;
|
||||
size?: number;
|
||||
radius?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square channel logo; shows the uploaded image or a deterministic
|
||||
* initials chip keyed to the channel name (like ErsatzTV's generated logos).
|
||||
*/
|
||||
export function ChannelLogo(props: ChannelLogoProps): JSX.Element;
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
const palette = [
|
||||
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
|
||||
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
|
||||
];
|
||||
|
||||
function hashIndex(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
return h % palette.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel logo tile. Renders an image when given, else a generated
|
||||
* initials chip keyed to the channel name — mirrors ErsatzTV's logo/gen.
|
||||
*/
|
||||
export function ChannelLogo({ name = "", src = null, size = 40, radius = "var(--radius-sm)", style = {} }) {
|
||||
const [fg, bg] = palette[hashIndex(name)];
|
||||
const initials = name
|
||||
.split(/[\s\-|:]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: size,
|
||||
height: size,
|
||||
flex: "0 0 auto",
|
||||
overflow: "hidden",
|
||||
borderRadius: radius,
|
||||
background: src ? "var(--ctv-bg-sunken)" : bg,
|
||||
border: "1px solid var(--border-hairline)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={name} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
||||
) : (
|
||||
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.34)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{initials}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**ChannelLogo** — square channel avatar. Given `src`, shows the logo; otherwise generates a stable initials chip + color from `name` (mirrors ErsatzTV's `iptv/logos/gen`).
|
||||
|
||||
```jsx
|
||||
<ChannelLogo name="Retro Cartoons" size={40} />
|
||||
<ChannelLogo name="News 24" src="/logos/news24.png" />
|
||||
```
|
||||
|
||||
Props: `name`, `src`, `size`, `radius`.
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 0–100. Pass null/undefined for an indeterminate sweep. */
|
||||
value?: number | null;
|
||||
tone?: "accent" | "ok" | "warn" | "error";
|
||||
height?: number;
|
||||
/** Append a mono percentage on the right. */
|
||||
showLabel?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim progress bar for library scans, playout builds and transcodes.
|
||||
*/
|
||||
export function ProgressBar(props: ProgressBarProps): JSX.Element;
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
|
||||
const toneColor = {
|
||||
accent: "var(--action-primary)",
|
||||
ok: "var(--status-ok)",
|
||||
warn: "var(--status-warn)",
|
||||
error: "var(--status-error)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin determinate/indeterminate progress bar — library scans, transcodes.
|
||||
*/
|
||||
export function ProgressBar({ value = null, tone = "accent", height = 6, showLabel = false, style = {} }) {
|
||||
const c = toneColor[tone] || toneColor.accent;
|
||||
const indeterminate = value == null;
|
||||
const pct = Math.max(0, Math.min(100, value ?? 0));
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px", ...style }}>
|
||||
<div style={{ position: "relative", flex: 1, height, borderRadius: "var(--radius-pill)", background: "var(--ctv-surface-3)", overflow: "hidden" }}>
|
||||
<style>{`@keyframes ctv-indet{0%{left:-40%}100%{left:100%}}`}</style>
|
||||
{indeterminate ? (
|
||||
<span style={{ position: "absolute", top: 0, bottom: 0, width: "40%", borderRadius: "inherit", background: c, animation: "ctv-indet 1.1s var(--ease-standard) infinite" }} />
|
||||
) : (
|
||||
<span style={{ position: "absolute", top: 0, left: 0, bottom: 0, width: `${pct}%`, borderRadius: "inherit", background: c, transition: "width var(--dur-slow) var(--ease-out)" }} />
|
||||
)}
|
||||
</div>
|
||||
{showLabel && !indeterminate && (
|
||||
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", minWidth: 34, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{Math.round(pct)}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
**ProgressBar** — slim bar for library scans, playout builds and transcodes. Omit `value` for an indeterminate sweep (queued/starting work).
|
||||
|
||||
```jsx
|
||||
<ProgressBar value={62} showLabel />
|
||||
<ProgressBar value={null} tone="accent" /> {/* scanning… */}
|
||||
```
|
||||
|
||||
Props: `value` (0–100 or null), `tone` (accent·ok·warn·error), `height`, `showLabel`.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StatProps {
|
||||
label: string;
|
||||
/** The metric value — rendered in tabular mono. */
|
||||
value: React.ReactNode;
|
||||
unit?: string;
|
||||
/** Small change indicator, e.g. "+3" or "2 warnings". */
|
||||
delta?: React.ReactNode;
|
||||
deltaTone?: "up" | "down" | "neutral";
|
||||
icon?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPI — mono tabular value with label, optional delta and icon.
|
||||
*/
|
||||
export function Stat(props: StatProps): JSX.Element;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user