Compare commits

..
Author SHA1 Message Date
Jason Dove eda0318868 use colorspace filter for some edge cases 2025-07-01 15:05:02 -05:00
Jason Dove ac413f731a qsv improvements 2025-07-01 14:47:11 -05:00
Jason DoveandGitHub 5a9d27e196 make yaml playout count an expression (#2102) 2025-07-01 16:24:11 +00:00
Jason DoveandGitHub cd4a9c1d16 fix hdhr endpoint classification (#2101) 2025-07-01 15:13:16 +00:00
Jason DoveandGitHub f6249d9fa4 channel logo and watermark fixes (#2100)
* channel logo and watermark fixes

* update changelog
2025-07-01 13:40:30 +00:00
Jason DoveandGitHub e2ffa70529 support episodedetails and musicvideo as top-level other video nfo tags (#2098) 2025-07-01 02:59:18 +00:00
Jason DoveandGitHub 3e07bc6136 fix history for playlists in yaml playouts (#2097) 2025-07-01 00:47:53 +00:00
Jason DoveandGitHub d6bfc2fd05 marathon playout history fixes (#2096) 2025-06-30 21:39:31 +00:00
Jason DoveandGitHub 35116c64cd fix potential crash with recent marathon updates (#2095) 2025-06-30 19:23:31 +00:00
Jason DoveandGitHub 037cee873f yaml marathon history (#2094)
* better playlist tests

* fix history for marathon content in yaml playouts
2025-06-30 19:05:09 +00:00
Jason DoveandGitHub cd28afcd91 dont reload appsettings.json at runtime (#2093)
* dont reload appsettings.json at runtime

* also disable here
2025-06-29 17:15:34 +00:00
Jason DoveandGitHub 7457301d3e yaml playout skip items expression (#2092) 2025-06-29 14:31:56 +00:00
Jason DoveandGitHub 7b7d378df7 run all mac builds on macos-14 (#2091) 2025-06-29 13:35:57 +00:00
Jason DoveandGitHub f6dcaf9108 fix qsv audio sync (#2090) 2025-06-29 03:49:04 +00:00
Jason DoveandGitHub 6cc2f1de17 yaml playout improvements (#2088)
* add stop_before_end

* more yaml playout improvements
2025-06-28 16:27:58 +00:00
Jason DoveandGitHub c6ee41484e allow other videos and images to use the same folders (#2087) 2025-06-28 14:50:33 +00:00
Jason DoveandGitHub 36d38c740f only scan plex networks on plex show libraries (#2086) 2025-06-28 12:56:49 +00:00
Jason DoveandGitHub 0f795e4e2f add plex network metadata (#2085)
* initial plumbing

* scan for plex networks

* save network contents to db as tags

* eliminate network tag id churn

* add network and show_network to search index

* update last networks scan

* show networks on tv show page

* update changelog
2025-06-28 12:49:26 +00:00
Jason DoveandGitHub 583cbf7b14 add channel active mode (#2083) 2025-06-27 21:19:26 +00:00
Jason DoveandGitHub 27c701b936 fix software tonemap with nvidia (#2082) 2025-06-27 20:47:09 +00:00
Jason DoveandGitHub 6e2c19d354 process missing language as und (#2081) 2025-06-27 14:45:30 +00:00
Jason DoveandGitHub 4d83dc019c don't return stream selection when subtitles don't match (#2080) 2025-06-27 14:25:07 +00:00
Jason DoveandGitHub 462057a4b1 prioritize stream selection by language (#2079) 2025-06-27 13:31:50 +00:00
Jason DoveandGitHub a04c72788f fix arm64 docker build (#2078) 2025-06-27 11:48:21 +00:00
Jason DoveandGitHub f94a440b62 stream selector improvements (#2077)
* add tests for audio blocklist and audio allowlist

* add subtitle allow list and block list

* add subtitle condition

* add audio condition

* cache bust mudblazor css
2025-06-27 11:40:06 +00:00
Jason DoveandGitHub f80069bb97 add custom channel stream selector system (#2076)
* add some basic channel stream selector models

* change windows ffmpeg url

* implement basic stream selection

* fixes
2025-06-27 03:00:59 +00:00
Jason DoveandGitHub c2769a08b4 stop building hwaccel-specific images (#2075)
* stop building hwaccel images

* update changelog
2025-06-26 17:26:11 +00:00
Jason Dove e679fee940 update CHANGELOG for clarity [no ci] 2025-06-24 19:11:04 -05:00
193 changed files with 120101 additions and 695 deletions
+3 -3
View File
@@ -33,10 +33,10 @@ jobs:
strategy:
matrix:
include:
- os: macos-13
- os: macos-14
kind: macOS
target: osx-x64
- os: macos-13
- os: macos-14
kind: macOS
target: osx-arm64
steps:
@@ -182,7 +182,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
target: ffmpeg/
- name: Build
+5 -13
View File
@@ -31,14 +31,6 @@ jobs:
path: ''
suffix: ''
qemu: false
- name: nvidia
path: 'nvidia/'
suffix: '-nvidia'
qemu: false
- name: vaapi
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm32v7
path: 'arm32v7/'
suffix: '-arm'
@@ -82,12 +74,12 @@ jobs:
file: ./docker/${{ matrix.path }}Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
INFO_VERSION=${{ inputs.info_version }}-docker
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.base_version }}
jasongdove/ersatztv:${{ inputs.tag_version }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-13
runs-on: macos-14
steps:
- name: Get the sources
uses: actions/checkout@v4
+82 -2
View File
@@ -4,6 +4,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add new channel stream (audio and subtitle) selector system
- Channel editor has a new field `Stream Selector Mode`
- `Default` maintains existing behavior
- `Custom` uses a YAML config file
- The YAML config contains a prioritized list of stream selector "items" (audio and subtitle criteria pairs)
- The items are tested against the media from top to bottom, and when (at least) a matching audio track is found, stream selection occurs
- As an example, the custom stream selector config can specify (in priority order):
- english audio (and disable subtitles)
- any other audio (and english subtitles, if they exist)
- Criteria can include
- Stream language
- Stream title (allowed title and/or blocked title)
- Stream condition, which is an expression that can use
- `id` (index)
- `title`
- `lang`
- `default`
- `forced`
- `sdh` (subtitle only)
- `external` (subtitle only)
- `codec`
- `channels` (audio only)
- An example subtitle condition: `lang like 'en%' and external`
- An example audio condition: `title like '%movie%' and channels > 2`
- Add new channel setting `Active Mode`
- `Active` - default value, channel streams as normal and has normal visibility
- `Hidden` - channel streams as normal and is hidden from M3U/XMLTV/HDHR
- `Inactive` - channel cannot stream (will 404) and is hidden from M3U/XMLTV/HDHR
- Synchronize Plex "network" metadata for Plex show libraries
- Shows will have new `network` search field
- Episodes will have new `show_network` search field
- YAML playout: add `stop_before_end` setting to `pad_until` and `duration` instructions
- When `stop_before_end: false`, content can run over the desired time before executing the next instruction
- YAML playout: add `offline_tail` setting to `pad_until` instruction
- This can be used to stop primary content before the desired time (`stop_before_end: true` and `offline_tail: false`)
- You can then have a second `pad_until` with the same target time and different content
- YAML playout: make `tomorrow` an expression on `pad_until` instruction
- `true` and `false` still work as normal
- The current time (as a decimal) can also be used in the expression, e.g. `now > 23`
- `now = hours + minutes / 60.0 + seconds / 3600.0`
- So `10:30 AM` would be `10.5`, `10:45 PM` would be `22.75`, etc
- YAML playout: make `skip_items` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will start in the middle of the content
- `random` will start at a random point in the content
- `2` (similar to before this change) will skip the first two items in the content
- YAML playout: make `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders
- Try to mitigate inotify limit error by disabling automatic reloading of `appsettings.json` config files
- Support `movie`, `musicvideo` and `episodedetails` top-level tags in other video NFO files
- Note that no change has been made to the metadata tags that are actually parsed, but this should help with various types of content
### Fixed
- Fix QSV acceleration in docker with older Intel devices
- Fix HDR transcoding with NVIDIA accel for:
- All NVIDIA docker users
- Windows NVIDIA users who have set the `ETV_DISABLE_VULKAN` env var
- Fix audio sync issue with QSV acceleration
- YAML playout: fix history for marathon and playlist content
- This allows playouts to be extended correctly, instead of always resetting to the earliest item in each group
- Fix using channel External Logo URL as watermark
- Fix display of SVG channel logo and watermark in admin UI
- Existing SVG logos and watermarks will have to be re-uploaded to display properly in the admin UI
- This does not affect streaming at all; existing artwork still works fine for streaming
- Classify HDHR endpoints as streaming endpoints
- This allows these endpoints to be accessed through port `ETV_STREAMING_PORT` (default `8409`)
- This only matters if you configured `ETV_UI_PORT` to be a different value, which makes UI endpoints inaccessible on the streaming port
## [25.2.0] - 2025-06-24
### Added
@@ -45,8 +125,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `collection` (name) to search index for manual collections created within ETV
- Collections synchronized from media servers are still indexed as `tag`
- Allow searching by `smart_collection` (name)
- Quotes are *always* required when using this feature
- e.g. `smart_collection:"one" NOT smart_collection:"two"`
- Quotes are *always* required around each collection name when using this feature
- e.g. `smart_collection:"one" OR smart_collection:"two"`
- Cycles will be detected and logged, and searches with cycles will not work as expected
- Add all `ETV_*` environment variables to Troubleshooting > General info
- Add `External Logo URL` field to channel editor
@@ -0,0 +1,17 @@
using System.Net;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record ArtworkContentTypeModel(string Path, string ContentType)
{
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using System.Net;
using ErsatzTV.Application.Artworks;
namespace ErsatzTV.Application.Channels;
@@ -10,7 +11,9 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -22,7 +25,8 @@ public record ChannelViewModel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode)
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -9,7 +10,9 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -20,4 +23,5 @@ public record CreateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -49,9 +49,9 @@ public class CreateChannelHandler(
fillerPresetId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo;
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
@@ -62,6 +62,7 @@ public class CreateChannelHandler(
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
@@ -77,13 +78,16 @@ public class CreateChannelHandler(
ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
StreamSelector = request.StreamSelector,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode
SongVideoMode = request.SongVideoMode,
ActiveMode = request.ActiveMode
};
foreach (int id in watermarkId)
@@ -49,6 +49,18 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int inactiveCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ActiveMode != ChannelActiveMode.Active)
.CountAsync(cancellationToken);
if (inactiveCount > 0)
{
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
@@ -85,8 +97,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
@@ -244,7 +254,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
}
@@ -118,7 +118,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout)
where C.Id in (select ChannelId from Playout) and C.ActiveMode = 0
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,7 +11,9 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
@@ -21,4 +24,5 @@ public record UpdateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSongVideoMode SongVideoMode,
ChannelActiveMode ActiveMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -1,5 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
@@ -35,6 +34,8 @@ public class UpdateChannelHandler(
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
@@ -42,11 +43,12 @@ public class UpdateChannelHandler(
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode;
c.ActiveMode = update.ActiveMode;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo))
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
{
string logo = update.Logo;
string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
@@ -56,6 +58,9 @@ public class UpdateChannelHandler(
foreach (Artwork artwork in maybeLogo)
{
artwork.Path = logo;
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null;
artwork.DateUpdated = DateTime.UtcNow;
}
@@ -64,6 +69,9 @@ public class UpdateChannelHandler(
var artwork = new Artwork
{
Path = logo,
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
+11 -5
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -14,6 +15,8 @@ internal static class Mapper
channel.Categories,
channel.FFmpegProfileId,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.ProgressMode,
@@ -25,7 +28,8 @@ internal static class Mapper
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode);
channel.SongVideoMode,
channel.ActiveMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -42,7 +46,7 @@ internal static class Mapper
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate);
private static string GetLogo(Channel channel)
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
@@ -50,10 +54,12 @@ internal static class Mapper
foreach (Artwork artwork in maybeArtwork)
{
return artwork.IsExternalUrl() ? artwork.Path : $"iptv/logos/{artwork.Path}";
return artwork.IsExternalUrl()
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
}
return string.Empty;
return ArtworkContentTypeModel.None;
}
private static string GetStreamingMode(Channel channel) =>
@@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
public class GetChannelByIdHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.GetChannel(request.Id)
channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
}
@@ -1,5 +1,7 @@
using System.Collections.Immutable;
using System.Text;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
@@ -29,6 +31,12 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var inactiveChannelNumbers = dbContext.Channels
.Where(c => c.ActiveMode != ChannelActiveMode.Active)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
@@ -60,6 +68,11 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
continue;
}
if (inactiveChannelNumbers.Contains(Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
channelDataFragment = channelDataFragment
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Channels;
@@ -11,5 +12,5 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
.Map(channels => channels.Where(c => c.ActiveMode is ChannelActiveMode.Active).Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}
@@ -28,6 +28,11 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (channel.ActiveMode is not ChannelActiveMode.Active)
{
continue;
}
switch (mode.ToLowerInvariant())
{
case "segmenter":
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamSelectors : IRequest<List<string>>;
@@ -0,0 +1,14 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Application.Channels;
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelStreamSelectors, List<string>>
{
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) =>
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder)
.Map(Path.GetFileName)
.ToList()
.AsTask();
}
@@ -4,4 +4,4 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images;
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind, string ContentType) : IRequest<Either<BaseError, string>>;
@@ -3,5 +3,5 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Images;
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null) : IRequest<
Either<BaseError, CachedImagePathViewModel>>;
@@ -42,7 +42,7 @@ public class
{
try
{
MimeType mimeType;
string mimeType;
string cachePath = _imageCache.GetPathForImage(
request.FileName,
@@ -84,7 +84,7 @@ public class
File.Move(withExtension, cachePath);
mimeType = new MimeType("image/jpeg");
mimeType = "image/jpeg";
}
else
{
@@ -93,10 +93,12 @@ public class
}
else
{
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath);
mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
? request.ContentType
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
}
return new CachedImagePathViewModel(cachePath, mimeType.Name);
return new CachedImagePathViewModel(cachePath, mimeType);
}
catch (Exception ex)
{
@@ -1,76 +0,0 @@
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
TvContext dbContext,
LocalLibrary localLibrary)
{
await dbContext.LocalLibraries.AddAsync(localLibrary);
await dbContext.SaveChangesAsync();
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
dbContext.LocalMediaSources
.OrderBy(lms => lms.Id)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(
lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Libraries;
public record CreateLocalLibraryPath(int LibraryId, string Path)
: IRequest<Either<BaseError, LocalLibraryPathViewModel>>;
@@ -1,51 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryPathHandler : IRequestHandler<CreateLocalLibraryPath,
Either<BaseError, LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync());
private Task<LocalLibraryPathViewModel> PersistLocalLibraryPath(LibraryPath p) =>
_libraryRepository.Add(p).Map(ProjectToViewModel);
private Task<Validation<BaseError, LibraryPath>> Validate(CreateLocalLibraryPath request) =>
ValidateFolder(request)
.MapT(
folder =>
new LibraryPath
{
LibraryId = request.LibraryId,
Path = folder
});
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalLibraryPath request)
{
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Where(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}
@@ -19,23 +19,44 @@ public abstract class LocalLibraryHandlerBase
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> allPaths = await dbContext.LocalLibraries
List<LocalPath> allPaths = await dbContext.LocalLibraries
.Include(ll => ll.Paths)
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
.ToListAsync()
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
.Map(list => list.SelectMany(ll => ll.Paths.Map(lp => new LocalPath(ll.MediaKind, lp.Path))).ToList());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
var localPaths = localLibrary.Paths.Map(lp => new LocalPath(localLibrary.MediaKind, lp.Path)).ToList();
return Optional(localPaths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder))))
.Where(length => length == 0)
.Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
private static bool AreSubPaths(LocalPath path1, LocalPath path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
string one = path1.Path + Path.DirectorySeparatorChar;
string two = path2.Path + Path.DirectorySeparatorChar;
bool isConflict = one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
// Images and OtherVideos do not conflict
if (isConflict)
{
bool imagesAndOtherVideos = (path1.MediaKind is LibraryMediaKind.Images &&
path2.MediaKind is LibraryMediaKind.OtherVideos)
|| (path2.MediaKind is LibraryMediaKind.Images &&
path1.MediaKind is LibraryMediaKind.OtherVideos);
if (imagesAndOtherVideos)
{
isConflict = false;
}
}
return isConflict;
}
protected record LocalPath(LibraryMediaKind MediaKind, string Path);
}
@@ -119,6 +119,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaKind = existing.MediaKind,
MediaSourceId = existing.Id
};
@@ -0,0 +1,85 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Plex;
public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>,
IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
{
public CallPlexNetworkScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizePlexNetworks request)
{
DateTime minDateTime = await dbContext.PlexLibraries
.Filter(l => l.MediaKind == LibraryMediaKind.Shows)
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
.Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizePlexNetworks request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizePlexNetworks request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-plex-networks", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -410,16 +410,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
{
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNoneAsync(new List<Subtitle>()),
.Map(mm => mm.Subtitles ?? [])
.IfNoneAsync([]),
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNoneAsync(new List<Subtitle>()),
.Map(mm => mm.Subtitles ?? [])
.IfNoneAsync([]),
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings),
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
.IfNoneAsync(new List<Subtitle>()),
_ => new List<Subtitle>()
.Map(mm => mm.Subtitles ?? [])
.IfNoneAsync([]),
_ => []
};
bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or
+6 -6
View File
@@ -22,21 +22,21 @@ internal static class Mapper
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
.IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(g => g.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId).Map(g => g.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone()
.Map(
m => (m.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone(new List<string>()),
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone([]),
LanguagesForShow(languages),
show.ShowMetadata.HeadOrNone()
.Map(
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList())
.IfNone(new List<ActorCardViewModel>()));
.IfNone([]));
internal static TelevisionSeasonViewModel ProjectToViewModel(
Season season,
@@ -13,6 +13,7 @@ public record TelevisionShowViewModel(
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> Networks,
List<string> ContentRatings,
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors);
@@ -137,9 +137,14 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
var environment = new Dictionary<string, string>();
foreach (DictionaryEntry de in Environment.GetEnvironmentVariables())
{
if (de is { Key: string key, Value: string value } && key.StartsWith("ETV_", StringComparison.OrdinalIgnoreCase))
if (de is { Key: string key, Value: string value })
{
environment[key] = value;
if (key.StartsWith("ETV_", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase)
|| key.StartsWith("ASPNETCORE_", StringComparison.OrdinalIgnoreCase))
{
environment[key] = value;
}
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
@@ -6,7 +7,7 @@ namespace ErsatzTV.Application.Watermarks;
public record CreateWatermark(
string Name,
string Image,
ArtworkContentTypeModel Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location,
@@ -39,21 +39,33 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
private static Validation<BaseError, ChannelWatermark> Validate(CreateWatermark request) =>
ValidateName(request)
.Map(
_ => new ChannelWatermark
_ =>
{
Name = request.Name,
Image = request.ImageSource == ChannelWatermarkImageSource.Custom ? request.Image : null,
Mode = request.Mode,
ImageSource = request.ImageSource,
Location = request.Location,
Size = request.Size,
WidthPercent = request.Width,
HorizontalMarginPercent = request.HorizontalMargin,
VerticalMarginPercent = request.VerticalMargin,
FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent
var watermark = new ChannelWatermark
{
Name = request.Name,
Image = null,
OriginalContentType = null,
Mode = request.Mode,
ImageSource = request.ImageSource,
Location = request.Location,
Size = request.Size,
WidthPercent = request.Width,
HorizontalMarginPercent = request.HorizontalMargin,
VerticalMarginPercent = request.VerticalMargin,
FrequencyMinutes = request.FrequencyMinutes,
DurationSeconds = request.DurationSeconds,
Opacity = request.Opacity,
PlaceWithinSourceContent = request.PlaceWithinSourceContent
};
if (request.ImageSource == ChannelWatermarkImageSource.Custom)
{
watermark.Image = request.Image?.Path;
watermark.OriginalContentType = request.Image?.ContentType;
}
return watermark;
});
private static Validation<BaseError, string> ValidateName(CreateWatermark request) =>
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
@@ -7,7 +8,7 @@ namespace ErsatzTV.Application.Watermarks;
public record UpdateWatermark(
int Id,
string Name,
string Image,
ArtworkContentTypeModel Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
WatermarkLocation Location,
@@ -33,7 +33,15 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
UpdateWatermark update)
{
p.Name = update.Name;
p.Image = update.ImageSource == ChannelWatermarkImageSource.Custom ? update.Image : null;
p.Image = null;
p.OriginalContentType = null;
if (update.ImageSource == ChannelWatermarkImageSource.Custom)
{
p.Image = update.Image?.Path;
p.OriginalContentType = update.Image?.ContentType;
}
p.Mode = update.Mode;
p.ImageSource = update.ImageSource;
p.Location = update.Location;
+3 -2
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
@@ -7,7 +8,7 @@ internal static class Mapper
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
watermark.Image,
new ArtworkContentTypeModel(watermark.Image, watermark.OriginalContentType),
watermark.Name,
watermark.Mode,
watermark.ImageSource,
@@ -16,7 +16,7 @@ public class GetWatermarkByIdHandler : IRequestHandler<GetWatermarkById, Option<
GetWatermarkById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == request.Id)
.MapT(ProjectToViewModel);
@@ -1,11 +1,12 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Application.Watermarks;
public record WatermarkViewModel(
int Id,
string Image,
ArtworkContentTypeModel Image,
string Name,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
@@ -0,0 +1,732 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Tests.Fakes;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.FFmpeg;
[TestFixture]
public class CustomStreamSelectorTests
{
[TestFixture]
public class SelectStreams
{
private static readonly string TestFileName = Path.Combine(FileSystemLayout.ChannelStreamSelectorsFolder, "test.yml");
private Channel _channel;
private MediaItemAudioVersion _audioVersion;
private List<Subtitle> _subtitles;
[SetUp]
public void SetUp()
{
_channel = new Channel(Guid.Empty)
{
StreamSelectorMode = ChannelStreamSelectorMode.Custom,
StreamSelector = TestFileName
};
_audioVersion = GetTestAudioVersion("eng");
_subtitles =
[
new Subtitle { Id = 1, Language = "eng", Title = "Words", SubtitleKind = SubtitleKind.Embedded },
new Subtitle { Id = 2, Language = "en", Title = "Signs" },
new Subtitle { Id = 3, Language = "en", Title = "Songs" },
new Subtitle { Id = 4, Language = "en", Forced = true, SubtitleKind = SubtitleKind.Sidecar },
new Subtitle { Id = 5, Language = "jp" }
];
}
[Test]
public async Task Should_Select_eng_Audio_Exact_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "eng"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_und_Audio_Missing_Language()
{
const string YAML =
"""
---
items:
- audio_language: ["und"]
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(3);
audioStream.Language.ShouldBeNullOrWhiteSpace();
}
}
[Test]
public async Task Should_Select_eng_Audio_Exact_Match_Multiple_Audio_Languages()
{
const string YAML =
"""
---
items:
- audio_language: ["en", "eng"]
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_eng_Audio_Exact_Match_Multiple_Items()
{
const string YAML =
"""
---
items:
- audio_language:
- "de"
subtitle_language:
- "eng"
- audio_language:
- "eng"
disable_subtitles: true
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_eng_Audio_Pattern_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_en_Audio_Pattern_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
""";
_audioVersion = GetTestAudioVersion("en");
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("en");
}
}
[Test]
public async Task disable_subtitles_Should_Select_No_Subtitles()
{
const string YAML =
"""
---
items:
- audio_language:
- "eng"
disable_subtitles: true
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_eng_Subtitle_Exact_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "ja"
subtitle_language:
- "eng"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(1);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_eng_Subtitle_Pattern_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "ja"
subtitle_language:
- "en*"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(1);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_en_Subtitle_Pattern_Match()
{
const string YAML =
"""
---
items:
- audio_language:
- "ja"
subtitle_language:
- "en*"
""";
_audioVersion = GetTestAudioVersion("en");
_subtitles =
[
new Subtitle { Id = 1, Language = "en", Title = "Words" }
];
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(1);
subtitle.Language.ShouldBe("en");
}
}
[Test]
public async Task Should_Select_No_Subtitle_Exact_Match_Multiple_Items()
{
const string YAML =
"""
---
items:
- audio_language:
- "de"
subtitle_language:
- "eng"
- audio_language:
- "eng"
disable_subtitles: true
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_Foreign_Audio_And_English_Subtitle_Multiple_Items()
{
const string YAML =
"""
---
items:
- audio_language:
- "ja"
subtitle_language:
- "eng"
- audio_language:
- "eng"
disable_subtitles: true
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(1);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Ignore_Blocked_Audio_Title()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
audio_title_blocklist:
- "riff"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(2);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_Allowed_Audio_Title()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
audio_title_allowlist:
- "movie"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(2);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Ignore_Blocked_Subtitle_Title()
{
const string YAML =
"""
---
items:
- audio_language:
- "*"
subtitle_language:
- "en"
subtitle_title_blocklist:
- "signs"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(3);
subtitle.Language.ShouldBe("en");
}
}
[Test]
public async Task Should_Select_Allowed_Subtitle_Title()
{
const string YAML =
"""
---
items:
- audio_language:
- "*"
subtitle_language:
- "en"
subtitle_title_allowlist:
- "songs"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(3);
subtitle.Language.ShouldBe("en");
}
}
[Test]
public async Task Should_Select_Condition_Forced_Subtitle()
{
const string YAML =
"""
---
items:
- audio_language:
- "*"
subtitle_condition: "forced"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(4);
subtitle.Language.ShouldBe("en");
}
}
[Test]
public async Task Should_Select_Condition_External_Subtitle()
{
const string YAML =
"""
---
items:
- audio_language:
- "*"
subtitle_condition: "lang like 'en%' and external"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(4);
subtitle.Language.ShouldBe("en");
}
}
[Test]
public async Task Should_Select_Condition_Audio_Title()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
audio_condition: "title like '%movie%'"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(2);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_Condition_Audio_Channels()
{
const string YAML =
"""
---
items:
- audio_language:
- "en*"
audio_condition: "channels > 2"
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(2);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_Prioritized_Audio_Language()
{
const string YAML =
"""
---
items:
- audio_language: ["en*","ja"]
audio_title_blocklist: ["riff"]
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(2);
audioStream.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_Prioritized_Subtitle_Language()
{
const string YAML =
"""
---
items:
- audio_language:
- "*"
subtitle_language: ["jp","en*"]
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(5);
subtitle.Language.ShouldBe("jp");
}
}
[Test]
public async Task Should_Select_No_Streams_When_Languages_Do_Not_Match()
{
const string YAML =
"""
---
items:
- audio_language: ["en"]
subtitle_language: ["es*","de*"]
- audio_language: ["ja"]
subtitle_language: ["es*","de*"]
""";
var streamSelector = new CustomStreamSelector(
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]),
new NullLogger<CustomStreamSelector>());
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeFalse();
result.Subtitle.IsSome.ShouldBeFalse();
}
private static MediaItemAudioVersion GetTestAudioVersion(string englishLanguage)
{
var mediaItem = new OtherVideo();
var mediaVersion = new MediaVersion
{
Streams =
[
new MediaStream
{
Index = 0,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Language = "ja",
Title = "Some Title",
},
new MediaStream
{
Index = 1,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Language = englishLanguage,
Title = "Riff Title",
Default = true
},
new MediaStream
{
Index = 2,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 6,
Language = englishLanguage,
Title = "Movie Title",
},
new MediaStream
{
Index = 3,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Title = "Who Knows",
}
]
};
return new MediaItemAudioVersion(mediaItem, mediaVersion);
}
}
}
@@ -24,7 +24,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -49,7 +48,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -70,7 +68,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -91,7 +88,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -114,7 +110,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -137,7 +132,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -158,7 +152,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -181,7 +174,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
now,
now.AddMinutes(5),
TimeSpan.Zero,
@@ -205,7 +197,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
now,
now.AddMinutes(5),
TimeSpan.Zero,
@@ -233,7 +224,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -260,7 +250,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -287,7 +276,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -315,7 +303,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -342,7 +329,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -372,7 +358,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -402,7 +387,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -432,7 +416,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -462,7 +445,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -494,7 +476,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -525,7 +506,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -556,7 +536,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream { Codec = "h264" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -586,7 +565,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -617,7 +595,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -647,7 +624,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -678,7 +654,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -704,7 +679,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -728,7 +702,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -752,7 +725,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -777,7 +749,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -802,7 +773,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -827,7 +797,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -852,7 +821,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -876,7 +844,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -900,7 +867,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -930,7 +896,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -954,7 +919,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -980,7 +944,6 @@ public class FFmpegPlaybackSettingsCalculatorTests
ffmpegProfile,
TestVersion,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
@@ -3,4 +3,6 @@
public record FakeFileEntry(string Path)
{
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
public string Contents { get; set; }
}
@@ -57,6 +57,12 @@ public class FakeLocalFileSystem : ILocalFileSystem
public Unit EmptyFolder(string folder) => Unit.Default;
public async Task<string> ReadAllText(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty);
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
private static List<DirectoryInfo> Split(DirectoryInfo path)
@@ -21,6 +21,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
@@ -63,15 +63,18 @@ public class PlaylistEnumeratorTests
shufflePlaylistItems: false,
CancellationToken.None);
var items = new List<int>();
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
var totalLength = 1;
while (enumerator.State.Index > 0)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
totalLength += 1;
}
totalLength.ShouldBe(8);
items.Count.ShouldBe(8);
items.ShouldBe([10, 20, 21, 30, 10, 20, 21, 31]);
}
[Test]
@@ -127,15 +130,18 @@ public class PlaylistEnumeratorTests
shufflePlaylistItems: false,
CancellationToken.None);
var items = new List<int>();
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
var totalLength = 1;
while (enumerator.State.Index > 0)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
totalLength += 1;
}
totalLength.ShouldBe(8);
items.Count.ShouldBe(8);
items.ShouldBe([10, 20, 30, 31, 10, 21, 30, 31]);
}
private static Movie FakeMovie(int id) => new()
+3
View File
@@ -23,6 +23,8 @@ public class Channel
public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
@@ -31,5 +33,6 @@ public class Channel
public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelProgressMode ProgressMode { get; set; }
public ChannelActiveMode ActiveMode { get; set; }
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelActiveMode
{
Active = 0,
Hidden = 1,
Inactive = 2
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelStreamSelectorMode
{
Default = 0,
Custom = 1
}
+1
View File
@@ -9,6 +9,7 @@ public class ChannelWatermark
public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; }
public string Image { get; set; }
public string OriginalContentType { get; set; }
public WatermarkLocation Location { get; set; }
public WatermarkSize Size { get; set; }
public int WidthPercent { get; set; }
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class PlexTag
{
public int Id { get; set; }
public string Filter { get; set; }
public string Tag { get; set; }
public int TagType { get; set; }
}
@@ -4,4 +4,5 @@ public class PlexLibrary : Library
{
public string Key { get; set; }
public bool ShouldSyncItems { get; set; }
public DateTime? LastNetworksScan { get; set; }
}
+1
View File
@@ -8,6 +8,7 @@ public class Artwork
public string BlurHash43 { get; set; }
public string BlurHash54 { get; set; }
public string BlurHash64 { get; set; }
public string OriginalContentType { get; set; }
public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
+3
View File
@@ -2,7 +2,10 @@
public class Tag
{
public static readonly string PlexNetworkTypeId = "319";
public int Id { get; set; }
public string Name { get; set; }
public string ExternalCollectionId { get; set; }
public string ExternalTypeId { get; set; }
}
+2
View File
@@ -2,6 +2,8 @@
public enum PlaybackOrder
{
None = 0,
Chronological = 1,
Random = 2,
Shuffle = 3,
@@ -15,6 +15,12 @@ public class PlayoutHistory
// something that uniquely identifies the collection within the block
public string Key { get; set; }
// something that uniquely identifies a child collection within the parent collection
// e.g. for a playlist
public string ChildKey { get; set; }
public bool IsCurrentChild { get; set; }
// last occurence of an item from this collection in the playout
public DateTime When { get; set; }
+1
View File
@@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NCalcSync" Version="5.4.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
@@ -0,0 +1,297 @@
using System.IO.Enumeration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg.Selector;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Core.FFmpeg;
public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<CustomStreamSelector> logger) : ICustomStreamSelector
{
public async Task<StreamSelectorResult> SelectStreams(Channel channel, MediaItemAudioVersion audioVersion, List<Subtitle> allSubtitles)
{
try
{
string streamSelectorFile = Path.Combine(
FileSystemLayout.ChannelStreamSelectorsFolder,
channel.StreamSelector);
if (!localFileSystem.FileExists(streamSelectorFile))
{
logger.LogWarning("YAML stream selector file {File} does not exist; aborting.", channel.StreamSelector);
return StreamSelectorResult.None;
}
StreamSelector streamSelector = await LoadStreamSelector(streamSelectorFile);
var audioStreams = audioVersion.MediaVersion.Streams
.Where(s => s.MediaStreamKind == MediaStreamKind.Audio)
.ToList();
foreach (StreamSelectorItem streamSelectorItem in streamSelector.Items)
{
var candidateAudioStreams = audioStreams.ToDictionary(a => a, _ => int.MaxValue);
var candidateSubtitles = allSubtitles.ToDictionary(s => s, _ => int.MaxValue);
var passesAudio = false;
var passesSubtitles = false;
// try to find matching audio stream
foreach (MediaStream audioStream in audioStreams.ToList())
{
var matches = false;
string safeTitle = (audioStream.Title ?? string.Empty).ToLowerInvariant();
string safeLanguage = (audioStream.Language ?? "und").ToLowerInvariant();
if (streamSelectorItem.AudioLanguages.Count > 0)
{
// match any of the listed languages
for (var langIndex = 0; langIndex < streamSelectorItem.AudioLanguages.Count; langIndex++)
{
string audioLanguage = streamSelectorItem.AudioLanguages[langIndex];
// special case
if (audioLanguage == "*")
{
matches = true;
}
matches = matches || FileSystemName.MatchesSimpleExpression(
audioLanguage.ToLowerInvariant(),
safeLanguage);
// store lang index for prioritizing later
if (matches && candidateAudioStreams[audioStream] == int.MaxValue)
{
candidateAudioStreams[audioStream] = langIndex;
}
}
}
else
{
matches = true;
}
if (streamSelectorItem.AudioTitleBlocklist
.Any(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase)))
{
matches = false;
}
if (streamSelectorItem.AudioTitleAllowlist.Count > 0)
{
int matchCount = streamSelectorItem.AudioTitleAllowlist
.Count(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase));
if (matchCount == 0)
{
matches = false;
}
}
if (!string.IsNullOrWhiteSpace(streamSelectorItem.AudioCondition))
{
if (!AudioMatchesCondition(audioStream, streamSelectorItem.AudioCondition))
{
matches = false;
}
}
if (!matches)
{
candidateAudioStreams.Remove(audioStream);
logger.LogDebug(
"Audio stream {@Stream} does not match selector item {@SelectorItem}",
new { Language = safeLanguage, Title = safeTitle },
streamSelectorItem);
}
else
{
passesAudio = true;
logger.LogDebug(
"Audio stream {@Stream} matches selector item {@SelectorItem}",
new { Language = safeLanguage, Title = safeTitle },
streamSelectorItem);
}
}
// try to find matching subtitle stream
if (streamSelectorItem.DisableSubtitles)
{
candidateSubtitles.Clear();
passesSubtitles = true;
}
else
{
foreach (Subtitle subtitle in allSubtitles.ToList())
{
var matches = false;
string safeTitle = (subtitle.Title ?? string.Empty).ToLowerInvariant();
string safeLanguage = (subtitle.Language ?? "und").ToLowerInvariant();
if (streamSelectorItem.SubtitleLanguages.Count > 0)
{
// match any of the listed languages
for (var langIndex = 0; langIndex < streamSelectorItem.SubtitleLanguages.Count; langIndex++)
{
string subtitleLanguage = streamSelectorItem.SubtitleLanguages[langIndex];
// special case
if (subtitleLanguage == "*")
{
matches = true;
}
matches = matches || FileSystemName.MatchesSimpleExpression(
subtitleLanguage,
safeLanguage);
// store lang index for prioritizing later
if (matches && candidateSubtitles[subtitle] == int.MaxValue)
{
candidateSubtitles[subtitle] = langIndex;
}
}
}
else
{
matches = true;
}
if (streamSelectorItem.SubtitleTitleBlocklist
.Any(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase)))
{
matches = false;
}
if (streamSelectorItem.SubtitleTitleAllowlist.Count > 0)
{
int matchCount = streamSelectorItem.SubtitleTitleAllowlist
.Count(block => safeTitle.Contains(block, StringComparison.OrdinalIgnoreCase));
if (matchCount == 0)
{
matches = false;
}
}
if (!string.IsNullOrWhiteSpace(streamSelectorItem.SubtitleCondition))
{
if (!SubtitleMatchesCondition(subtitle, streamSelectorItem.SubtitleCondition))
{
matches = false;
}
}
if (!matches)
{
candidateSubtitles.Remove(subtitle);
logger.LogDebug(
"Subtitle {@Subtitle} does not match selector item {@SelectorItem}",
new { Language = safeLanguage, Title = safeTitle },
streamSelectorItem);
}
else
{
passesSubtitles = true;
logger.LogDebug(
"Subtitle {@Subtitle} matches selector item {@SelectorItem}",
new { Language = safeLanguage, Title = safeTitle },
streamSelectorItem);
}
}
}
Option<MediaStream> maybeAudioStream = candidateAudioStreams
.OrderBy(a => a.Value)
.Select(a => a.Key)
.HeadOrNone();
Option<Subtitle> maybeSubtitle = candidateSubtitles
.OrderBy(s => s.Value)
.Select(s => s.Key)
.HeadOrNone();
if (passesAudio && passesSubtitles)
{
return new StreamSelectorResult(maybeAudioStream, maybeSubtitle);
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error selecting streams");
}
return StreamSelectorResult.None;
}
private static bool AudioMatchesCondition(MediaStream audioStream, string audioCondition)
{
var expression = new NCalc.Expression(audioCondition);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"id" => audioStream.Index,
"title" => (audioStream.Title ?? string.Empty).ToLowerInvariant(),
"lang" => (audioStream.Language ?? string.Empty).ToLowerInvariant(),
"default" => audioStream.Default,
"forced" => audioStream.Forced,
"codec" => (audioStream.Codec ?? string.Empty).ToLowerInvariant(),
"channels" => audioStream.Channels,
_ => e.Result
};
};
return expression.Evaluate() as bool? == true;
}
private static bool SubtitleMatchesCondition(Subtitle subtitle, string subtitleCondition)
{
var expression = new NCalc.Expression(subtitleCondition);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"id" => subtitle.StreamIndex,
"title" => (subtitle.Title ?? string.Empty).ToLowerInvariant(),
"lang" => (subtitle.Language ?? string.Empty).ToLowerInvariant(),
"default" => subtitle.Default,
"forced" => subtitle.Forced,
"sdh" => subtitle.SDH,
"codec" => (subtitle.Codec ?? string.Empty).ToLowerInvariant(),
"external" => subtitle.SubtitleKind is SubtitleKind.Sidecar,
_ => e.Result
};
};
return expression.Evaluate() as bool? == true;
}
private async Task<StreamSelector> LoadStreamSelector(string streamSelectorFile)
{
try
{
string yaml = await localFileSystem.ReadAllText(streamSelectorFile);
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
return deserializer.Deserialize<StreamSelector>(yaml);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error loading YAML stream selector");
throw;
}
}
}
@@ -20,6 +20,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ICustomStreamSelector _customStreamSelector;
private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
private readonly ITempFilePool _tempFilePool;
@@ -27,6 +28,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public FFmpegLibraryProcessService(
FFmpegProcessService ffmpegProcessService,
IFFmpegStreamSelector ffmpegStreamSelector,
ICustomStreamSelector customStreamSelector,
ITempFilePool tempFilePool,
IPipelineBuilderFactory pipelineBuilderFactory,
IConfigElementRepository configElementRepository,
@@ -34,6 +36,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
_ffmpegProcessService = ffmpegProcessService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_customStreamSelector = customStreamSelector;
_tempFilePool = tempFilePool;
_pipelineBuilderFactory = pipelineBuilderFactory;
_configElementRepository = configElementRepository;
@@ -73,20 +76,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Action<FFmpegPipeline> pipelineAction)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
Option<MediaStream> maybeAudioStream =
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel,
preferredAudioLanguage,
preferredAudioTitle);
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
videoVersion,
videoStream,
maybeAudioStream,
start,
now,
inPoint,
@@ -96,12 +91,40 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
List<Subtitle> allSubtitles = await getSubtitles(playbackSettings);
Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
allSubtitles,
channel,
preferredSubtitleLanguage,
subtitleMode);
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
{
StreamSelectorResult result = await _customStreamSelector.SelectStreams(channel, audioVersion, allSubtitles);
maybeAudioStream = result.AudioStream;
maybeSubtitle = result.Subtitle;
if (maybeAudioStream.IsNone)
{
_logger.LogWarning(
"No audio stream found using custom stream selector {StreamSelector}; will use default stream selection logic",
channel.StreamSelector);
}
}
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone)
{
maybeAudioStream =
await _ffmpegStreamSelector.SelectAudioStream(
audioVersion,
channel.StreamingMode,
channel,
preferredAudioLanguage,
preferredAudioTitle);
maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream(
allSubtitles,
channel,
preferredSubtitleLanguage,
subtitleMode);
}
foreach (Subtitle subtitle in maybeSubtitle)
{
@@ -45,7 +45,6 @@ public static class FFmpegPlaybackSettingsCalculator
FFmpegProfile ffmpegProfile,
MediaVersion videoVersion,
Option<MediaStream> videoStream,
Option<MediaStream> audioStream,
DateTimeOffset start,
DateTimeOffset now,
TimeSpan inPoint,
@@ -151,13 +150,7 @@ public static class FFmpegPlaybackSettingsCalculator
result.AudioFormat = ffmpegProfile.AudioFormat;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
foreach (MediaStream _ in audioStream)
{
// this can be optimized out later, depending on the audio codec
result.AudioChannels = ffmpegProfile.AudioChannels;
}
result.AudioChannels = ffmpegProfile.AudioChannels;
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = outPoint - inPoint;
result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode;
+5 -4
View File
@@ -97,7 +97,6 @@ public class FFmpegProcessService
channel.FFmpegProfile,
videoVersion,
videoStream,
None,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
TimeSpan.Zero,
@@ -105,6 +104,8 @@ public class FFmpegProcessService
false,
Option<int>.None);
scalePlaybackSettings.AudioChannels = Option<int>.None;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(1)
.WithQuiet()
@@ -204,7 +205,7 @@ public class FFmpegProcessService
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
.Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
@@ -241,7 +242,7 @@ public class FFmpegProcessService
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
.Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(channel.Watermark),
maybeChannelPath,
@@ -277,7 +278,7 @@ public class FFmpegProcessService
channel.Artwork
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
.Map(a => Artwork.IsExternalUrl(a.Path) ? a.Path : _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
maybeChannelPath,
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.FFmpeg.Selector;
public enum StreamMetadata
{
None = 0,
Default = 1,
Forced = 2
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.FFmpeg.Selector;
public sealed class StreamSelector
{
public List<StreamSelectorItem> Items { get; set; } = [];
}
@@ -0,0 +1,33 @@
using YamlDotNet.Serialization;
namespace ErsatzTV.Core.FFmpeg.Selector;
public class StreamSelectorItem
{
[YamlMember(Alias = "audio_language", ApplyNamingConventions = false)]
public List<string> AudioLanguages { get; set; } = [];
[YamlMember(Alias = "audio_title_allowlist", ApplyNamingConventions = false)]
public List<string> AudioTitleAllowlist { get; set; } = [];
[YamlMember(Alias = "audio_title_blocklist", ApplyNamingConventions = false)]
public List<string> AudioTitleBlocklist { get; set; } = [];
[YamlMember(Alias = "audio_condition", ApplyNamingConventions = false)]
public string AudioCondition { get; set; }
[YamlMember(Alias = "disable_subtitles", ApplyNamingConventions = false)]
public bool DisableSubtitles { get; set; }
[YamlMember(Alias = "subtitle_language", ApplyNamingConventions = false)]
public List<string> SubtitleLanguages { get; set; } = [];
[YamlMember(Alias = "subtitle_title_allowlist", ApplyNamingConventions = false)]
public List<string> SubtitleTitleAllowlist { get; set; } = [];
[YamlMember(Alias = "subtitle_title_blocklist", ApplyNamingConventions = false)]
public List<string> SubtitleTitleBlocklist { get; set; } = [];
[YamlMember(Alias = "subtitle_condition", ApplyNamingConventions = false)]
public string SubtitleCondition { get; set; }
}
+4
View File
@@ -107,6 +107,8 @@ public static class FileSystemLayout
MultiEpisodeShuffleTemplatesFolder = Path.Combine(ScriptsFolder, "multi-episode-shuffle");
AudioStreamSelectorScriptsFolder = Path.Combine(ScriptsFolder, "audio-stream-selector");
ChannelStreamSelectorsFolder = Path.Combine(ScriptsFolder, "channel-stream-selectors");
}
public static readonly string AppDataFolder;
@@ -156,6 +158,8 @@ public static class FileSystemLayout
public static readonly string AudioStreamSelectorScriptsFolder;
public static readonly string ChannelStreamSelectorsFolder;
public static readonly string MacOsOldAppDataFolder = Path.Combine(
Environment.GetEnvironmentVariable("HOME") ?? string.Empty,
".local",
@@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public interface ICustomStreamSelector
{
Task<StreamSelectorResult> SelectStreams(Channel channel, MediaItemAudioVersion audioVersion, List<Subtitle> allSubtitles);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.FFmpeg;
public record StreamSelectorResult(Option<MediaStream> AudioStream, Option<Subtitle> Subtitle)
{
public static readonly StreamSelectorResult None = new(Option<MediaStream>.None, Option<Subtitle>.None);
}
@@ -14,4 +14,5 @@ public interface ILocalFileSystem
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path);
}
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexNetworkScanner
{
Task<Either<BaseError, Unit>> ScanNetworks(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken);
}
@@ -76,4 +76,16 @@ public interface IPlexServerApiClient
PlexServerAuthToken token,
string key,
CancellationToken cancellationToken);
IAsyncEnumerable<Tuple<PlexTag, int>> GetAllTags(
PlexConnection connection,
PlexServerAuthToken token,
int tagType,
CancellationToken cancellationToken);
IAsyncEnumerable<Tuple<PlexShow, int>> GetTagShowContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token,
PlexTag tag);
}
@@ -14,7 +14,7 @@ public interface ILibraryRepository
Task<int> CountMediaItemsByPath(int libraryPathId);
Task SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task CleanEtagsForLibraryPath(LibraryPath libraryPath);
Task<Option<int>> GetParentFolderId(string folder);
Task<Option<int>> GetParentFolderId(LibraryPath libraryPath, string folder);
Task<LibraryFolder> GetOrAddFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder);
Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId);
Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath);
@@ -6,4 +6,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason,
PlexEpisode, PlexItemEtag>
{
Task<List<int>> RemoveAllTags(PlexLibrary library, PlexTag tag, System.Collections.Generic.HashSet<int> keep);
Task<PlexShowAddTagResult> AddTag(PlexShow show, PlexTag tag);
Task UpdateLastNetworksScan(PlexLibrary library);
}
public record PlexShowAddTagResult(Option<int> Existing, Option<int> Added);
@@ -14,6 +14,7 @@ public interface ITelevisionRepository
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId);
Task<List<int>> GetEpisodeIdsForShow(int showId);
Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId);
Task<int> GetSeasonCount(int showId);
@@ -164,4 +164,6 @@ public class LocalFileSystem : ILocalFileSystem
return Unit.Default;
}
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
}
+34 -4
View File
@@ -30,11 +30,36 @@ internal static class HistoryDetails
}
public static string KeyForYamlContent(YamlPlayoutContentItem contentItem)
{
var key = new Dictionary<string, object>
{
{ "Key", contentItem.Key },
{ "Order", contentItem.Order }
};
// we need to ignore history when any of these properties change
if (contentItem is YamlPlayoutContentMarathonItem marathonItem)
{
key["ItemOrder"] = marathonItem.ItemOrder;
//key.ShuffleGroups = marathonItem.ShuffleGroups;
key["GroupBy"] = marathonItem.GroupBy;
//key.PlayAllItems = marathonItem.PlayAllItems;
}
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
}
public static string KeyForCollectionKey(CollectionKey collectionKey)
{
dynamic key = new
{
contentItem.Key,
contentItem.Order
collectionKey.CollectionType,
collectionKey.CollectionId,
collectionKey.MultiCollectionId,
collectionKey.SmartCollectionId,
collectionKey.MediaItemId,
collectionKey.PlaylistId,
collectionKey.FakeCollectionKey,
};
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
@@ -71,7 +96,8 @@ internal static class HistoryDetails
List<MediaItem> collectionItems,
string detailsString,
IMediaCollectionEnumerator enumerator,
PlaybackOrder playbackOrder)
PlaybackOrder playbackOrder,
bool current = false)
{
if (playbackOrder is PlaybackOrder.Random)
{
@@ -143,7 +169,11 @@ internal static class HistoryDetails
Index = copy.IndexOf(matchedItem)
};
enumerator.ResetState(state);
enumerator.MoveNext();
if (!current)
{
enumerator.MoveNext();
}
}
}
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -45,6 +46,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int Count => throw new NotSupportedException("Count isn't used for playlist enumeration");
public int CountForRandom => _allMediaItemIds.Count;
public Option<TimeSpan> MinimumDuration { get; private set; }
public void MoveNext()
@@ -77,6 +80,15 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
}
}
public ImmutableList<PlaylistEnumeratorCollectionKey> ChildEnumerators { get; private set; }
public int EnumeratorIndex => _enumeratorIndex;
public void SetEnumeratorIndex(int enumeratorIndex)
{
_enumeratorIndex = enumeratorIndex % _sortedEnumerators.Count;
}
public static async Task<PlaylistEnumerator> Create(
IMediaCollectionRepository mediaCollectionRepository,
Dictionary<PlaylistItem, List<MediaItem>> playlistItemMap,
@@ -203,6 +215,17 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
}
}
var childEnumerators = new List<PlaylistEnumeratorCollectionKey>();
foreach (IMediaCollectionEnumerator enumerator in result._sortedEnumerators)
{
foreach ((CollectionKey collectionKey, _) in enumeratorMap.Find(e => e.Value == enumerator))
{
childEnumerators.Add(new PlaylistEnumeratorCollectionKey(enumerator, collectionKey));
}
}
result.ChildEnumerators = childEnumerators.ToImmutableList();
return result;
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling;
public record PlaylistEnumeratorCollectionKey(IMediaCollectionEnumerator Enumerator, CollectionKey CollectionKey);
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepository, ILogger logger)
{
private readonly Dictionary<string, List<MediaItem>> _mediaItems = new();
private readonly Dictionary<PlaylistKey, List<MediaItem>> _playlistMediaItems = new();
private readonly Dictionary<string, IMediaCollectionEnumerator> _enumerators = new();
public System.Collections.Generic.HashSet<string> MissingContentKeys { get; } = [];
@@ -17,6 +18,11 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
public List<MediaItem> MediaItemsForContent(string contentKey) =>
_mediaItems.TryGetValue(contentKey, out List<MediaItem> items) ? items : [];
public List<MediaItem> PlaylistMediaItemsForContent(string contentKey, CollectionKey collectionKey) =>
_playlistMediaItems.TryGetValue(new PlaylistKey(contentKey, collectionKey), out List<MediaItem> items)
? items
: [];
public async Task<Option<IMediaCollectionEnumerator>> GetCachedEnumeratorForContent(
YamlPlayoutContext context,
string contentKey,
@@ -91,13 +97,22 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
if (content is YamlPlayoutContentMarathonItem marathon)
{
var helper = new YamlPlayoutMarathonHelper(mediaCollectionRepository);
return await helper.GetEnumerator(marathon, state, cancellationToken);
Option<YamlMarathonContentResult> maybeResult = await helper.GetEnumerator(marathon, state, cancellationToken);
foreach (YamlMarathonContentResult result in maybeResult)
{
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in result.Content)
{
_playlistMediaItems.Add(new PlaylistKey(contentKey, collectionKey), mediaItems);
}
return Some(result.PlaylistEnumerator);
}
}
// playlist is a special case that needs to be handled on its own
if (content is YamlPlayoutContentPlaylistItem playlist)
{
if (!string.IsNullOrWhiteSpace(playlist.Order))
if (!string.IsNullOrWhiteSpace(playlist.Order) && !string.Equals(playlist.Order, "none", StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning(
"Ignoring playback order {Order} for playlist {Playlist}",
@@ -108,6 +123,13 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
Dictionary<PlaylistItem, List<MediaItem>> itemMap =
await mediaCollectionRepository.GetPlaylistItemMap(playlist.PlaylistGroup, playlist.Playlist);
foreach ((PlaylistItem playlistItem, List<MediaItem> mediaItems) in itemMap)
{
_playlistMediaItems.Add(
new PlaylistKey(contentKey, CollectionKey.ForPlaylistItem(playlistItem)),
mediaItems);
}
return await PlaylistEnumerator.Create(
mediaCollectionRepository,
itemMap,
@@ -130,4 +152,6 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
return Option<IMediaCollectionEnumerator>.None;
}
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
}
@@ -61,12 +61,13 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
context.AdvanceGuideGroup();
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(
List<PlayoutHistory> maybeHistory = GetHistoryForItem(
context,
instruction.Content,
enumerator,
playoutItem,
mediaItem);
mediaItem,
logger);
foreach (PlayoutHistory history in maybeHistory)
{
@@ -38,31 +38,114 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache)
string historyKey = HistoryDetails.KeyForYamlContent(contentItem);
DateTime historyTime = context.CurrentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = context.Playout.PlayoutHistory
Option<DateTime> maxWhen = await context.Playout.PlayoutHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
.Map(h => h.When)
.OrderByDescending(h => h)
.HeadOrNone()
.IfNoneAsync(DateTime.MinValue);
var maybeHistory = context.Playout.PlayoutHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When == maxWhen)
.ToList();
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
{
List<MediaItem> collectionItems = enumeratorCache.MediaItemsForContent(contentItem.Key);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
if (enumerator is PlaylistEnumerator playlistEnumerator)
{
logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
Option<PlayoutHistory> maybePrimaryHistory = maybeHistory
.Filter(h => string.IsNullOrWhiteSpace(h.ChildKey))
.HeadOrNone();
enumerator.ResetState(
new CollectionEnumeratorState { Seed = enumerator.State.Seed, Index = h.Index + 1 });
if (playbackOrder is PlaybackOrder.Chronological)
foreach (PlayoutHistory primaryHistory in maybePrimaryHistory)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
playbackOrder);
var hasSetEnumeratorIndex = false;
var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in playlistEnumerator.ChildEnumerators)
{
PlaybackOrder itemPlaybackOrder = childEnumerator switch
{
ChronologicalMediaCollectionEnumerator => PlaybackOrder.Chronological,
RandomizedMediaCollectionEnumerator => PlaybackOrder.Random,
ShuffledMediaCollectionEnumerator => PlaybackOrder.Shuffle,
_ => PlaybackOrder.None
};
Option<PlayoutHistory> maybeApplicableHistory = maybeHistory
.Filter(h => h.ChildKey == HistoryDetails.KeyForCollectionKey(collectionKey))
.HeadOrNone();
List<MediaItem> collectionItems =
enumeratorCache.PlaylistMediaItemsForContent(contentItem.Key, collectionKey);
foreach (PlayoutHistory h in maybeApplicableHistory)
{
// logger.LogDebug(
// "History is applicable: {When}: {ChildKey} / {History} / {IsCurrentChild}",
// h.When,
// h.ChildKey,
// h.Details,
// h.IsCurrentChild);
enumerator.ResetState(new CollectionEnumeratorState
{
Seed = enumerator.State.Seed,
Index = h.Index + (h.IsCurrentChild ? 1 : 0)
});
if (itemPlaybackOrder is PlaybackOrder.Chronological)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
childEnumerator,
itemPlaybackOrder,
true);
}
if (h.IsCurrentChild)
{
// try to find enumerator based on collection key
playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
hasSetEnumeratorIndex = true;
}
}
}
if (!hasSetEnumeratorIndex)
{
// falling back to enumerator based on index
playlistEnumerator.SetEnumeratorIndex(primaryHistory.Index);
}
// only move next at the end, because that may also move
// the enumerator index
playlistEnumerator.MoveNext();
}
}
else
{
List<MediaItem> collectionItems = enumeratorCache.MediaItemsForContent(contentItem.Key);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
// logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
enumerator.ResetState(
new CollectionEnumeratorState { Seed = enumerator.State.Seed, Index = h.Index + 1 });
if (playbackOrder is PlaybackOrder.Chronological)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
playbackOrder);
}
}
}
}
@@ -1,3 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@@ -47,40 +49,90 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache)
return maybeEnumerator;
}
protected static Option<PlayoutHistory> GetHistoryForItem(
protected static List<PlayoutHistory> GetHistoryForItem(
YamlPlayoutContext context,
string contentKey,
IMediaCollectionEnumerator enumerator,
PlayoutItem playoutItem,
MediaItem mediaItem)
MediaItem mediaItem,
ILogger<YamlPlayoutBuilder> logger)
{
int index = context.Definition.Content.FindIndex(c => c.Key == contentKey);
if (index < 0)
{
return Option<PlayoutHistory>.None;
logger.LogDebug("Unable to find history for content matching key {ContentKey}", contentKey);
return [];
}
YamlPlayoutContentItem contentItem = context.Definition.Content[index];
if (!Enum.TryParse(contentItem.Order, true, out PlaybackOrder playbackOrder))
{
return Option<PlayoutHistory>.None;
logger.LogDebug("Unable to find history for content matching playback order {PlaybackOrder}", contentItem.Order);
return [];
}
var result = new List<PlayoutHistory>();
string historyKey = HistoryDetails.KeyForYamlContent(contentItem);
// create a playout history record
var nextHistory = new PlayoutHistory
if (enumerator is PlaylistEnumerator playlistEnumerator)
{
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = enumerator.State.Index,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
// create a playout history record
var nextHistory = new PlayoutHistory
{
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = playlistEnumerator.EnumeratorIndex,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
return nextHistory;
result.Add(nextHistory);
for (var i = 0; i < playlistEnumerator.ChildEnumerators.Count; i++)
{
(IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) = playlistEnumerator.ChildEnumerators[i];
bool isCurrentChild = i == playlistEnumerator.EnumeratorIndex;
foreach (MediaItem currentMediaItem in childEnumerator.Current)
{
// create a playout history record
var childHistory = new PlayoutHistory
{
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = childEnumerator.State.Index,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,
ChildKey = HistoryDetails.KeyForCollectionKey(collectionKey),
IsCurrentChild = isCurrentChild,
Details = HistoryDetails.ForMediaItem(currentMediaItem)
};
result.Add(childHistory);
}
}
}
else
{
// create a playout history record
var nextHistory = new PlayoutHistory
{
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = enumerator.State.Index,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
result.Add(nextHistory);
}
return result;
}
protected static TimeSpan DurationForMediaItem(MediaItem mediaItem)
@@ -28,7 +28,30 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
{
for (var i = 0; i < count.Count; i++)
var random = new Random(context.Playout.Seed + context.InstructionIndex);
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
? playlistEnumerator.CountForRandom
: enumerator.Count;
var expression = new NCalc.Expression(count.Count);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"count" => enumeratorCount,
"random" => random.Next() % enumeratorCount,
_ => e.Result
};
};
object expressionResult = expression.Evaluate();
int countValue = expressionResult switch
{
double doubleResult => (int)Math.Floor(doubleResult),
int intResult => intResult,
_ => 0
};
for (var i = 0; i < countValue; i++)
{
foreach (MediaItem mediaItem in enumerator.Current)
{
@@ -61,12 +84,13 @@ public class YamlPlayoutCountHandler(EnumeratorCache enumeratorCache) : YamlPlay
context.AdvanceGuideGroup();
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(
List<PlayoutHistory> maybeHistory = GetHistoryForItem(
context,
instruction.Content,
enumerator,
playoutItem,
mediaItem);
mediaItem,
logger);
foreach (PlayoutHistory history in maybeHistory)
{
@@ -28,6 +28,12 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
return false;
}
if (duration.StopBeforeEnd == false && duration.OfflineTail)
{
logger.LogError("offline_tail must be false when stop_before_end is false");
return false;
}
DateTimeOffset targetTime = context.CurrentTime.Add(timeSpan);
Option<IMediaCollectionEnumerator> maybeEnumerator = await GetContentEnumerator(
@@ -49,13 +55,15 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
instruction.Content,
duration.Fallback,
targetTime,
duration.StopBeforeEnd,
duration.DiscardAttempts,
duration.Trim,
duration.OfflineTail,
GetFillerKind(duration),
duration.CustomTitle,
enumerator,
fallbackEnumerator);
fallbackEnumerator,
logger);
return true;
}
@@ -68,13 +76,15 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
string contentKey,
string fallbackContentKey,
DateTimeOffset targetTime,
bool stopBeforeEnd,
int discardAttempts,
bool trim,
bool offlineTail,
FillerKind fillerKind,
string customTitle,
IMediaCollectionEnumerator enumerator,
Option<IMediaCollectionEnumerator> fallbackEnumerator)
Option<IMediaCollectionEnumerator> fallbackEnumerator,
ILogger<YamlPlayoutBuilder> logger)
{
bool done = false;
TimeSpan remainingToFill = targetTime - context.CurrentTime;
@@ -97,18 +107,19 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
//DisableWatermarks = !allowWatermarks
};
if (remainingToFill - itemDuration >= TimeSpan.Zero)
if (remainingToFill - itemDuration >= TimeSpan.Zero || !stopBeforeEnd)
{
context.Playout.Items.Add(playoutItem);
context.AdvanceGuideGroup();
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(
List<PlayoutHistory> maybeHistory = GetHistoryForItem(
context,
contentKey,
enumerator,
playoutItem,
mediaItem);
mediaItem,
logger);
foreach (PlayoutHistory history in maybeHistory)
{
@@ -136,12 +147,13 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
context.AdvanceGuideGroup();
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(
List<PlayoutHistory> maybeHistory = GetHistoryForItem(
context,
contentKey,
enumerator,
playoutItem,
mediaItem);
mediaItem,
logger);
foreach (PlayoutHistory history in maybeHistory)
{
@@ -171,12 +183,13 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
context.Playout.Items.Add(playoutItem);
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(
List<PlayoutHistory> maybeHistory = GetHistoryForItem(
context,
fallbackContentKey,
fallback,
playoutItem,
mediaItem);
mediaItem,
logger);
foreach (PlayoutHistory history in maybeHistory)
{
@@ -195,6 +208,11 @@ public class YamlPlayoutDurationHandler(EnumeratorCache enumeratorCache) : YamlP
}
}
if (!stopBeforeEnd)
{
return context.CurrentTime;
}
return offlineTail ? targetTime : context.CurrentTime;
}
}
@@ -57,13 +57,15 @@ public class YamlPlayoutPadToNextHandler(EnumeratorCache enumeratorCache) : Yaml
padToNext.Content,
padToNext.Fallback,
targetTime,
false,
padToNext.DiscardAttempts,
padToNext.Trim,
offlineTail: true,
GetFillerKind(padToNext),
padToNext.CustomTitle,
enumerator,
fallbackEnumerator);
fallbackEnumerator,
logger);
return true;
}
@@ -29,7 +29,17 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP
if (timeOnly > result)
{
if (padUntil.Tomorrow)
var expression = new NCalc.Expression(padUntil.Tomorrow);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"now" => timeOnly.Hour + timeOnly.Minute / 60.0 + timeOnly.Second / 3600.0,
_ => e.Result
};
};
if (expression.Evaluate() as bool? == true)
{
// this is wrong when offset changes
dayOnly = dayOnly.AddDays(1);
@@ -67,13 +77,15 @@ public class YamlPlayoutPadUntilHandler(EnumeratorCache enumeratorCache) : YamlP
padUntil.Content,
padUntil.Fallback,
targetTime,
padUntil.StopBeforeEnd,
padUntil.DiscardAttempts,
padUntil.Trim,
offlineTail: true,
padUntil.OfflineTail,
GetFillerKind(padUntil),
padUntil.CustomTitle,
enumerator,
fallbackEnumerator);
fallbackEnumerator,
logger);
return true;
}
@@ -20,12 +20,6 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam
return false;
}
if (skipItems.SkipItems < 0)
{
logger.LogWarning("Unable to skip invalid number: {Skip}", skipItems.SkipItems);
return false;
}
Option<IMediaCollectionEnumerator> maybeEnumerator = await enumeratorCache.GetCachedEnumeratorForContent(
context,
skipItems.Content,
@@ -33,7 +27,38 @@ public class YamlPlayoutSkipItemsHandler(EnumeratorCache enumeratorCache) : IYam
foreach (IMediaCollectionEnumerator enumerator in maybeEnumerator)
{
for (var i = 0; i < skipItems.SkipItems; i++)
var random = new Random(context.Playout.Seed + context.InstructionIndex);
int enumeratorCount = enumerator is PlaylistEnumerator playlistEnumerator
? playlistEnumerator.CountForRandom
: enumerator.Count;
var expression = new NCalc.Expression(skipItems.SkipItems);
expression.EvaluateParameter += (name, e) =>
{
e.Result = name switch
{
"count" => enumeratorCount,
"random" => random.Next() % enumeratorCount,
_ => e.Result
};
};
object expressionResult = expression.Evaluate();
int skipCount = expressionResult switch
{
double doubleResult => (int)Math.Floor(doubleResult),
int intResult => intResult,
_ => 0
};
skipCount %= enumerator.Count;
if (skipCount < 0)
{
logger.LogWarning("Unable to skip invalid number: {Skip}", skipItems.SkipItems);
return false;
}
for (var i = 0; i < skipCount; i++)
{
enumerator.MoveNext();
}
@@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
public class YamlPlayoutContentItem
{
public string Key { get; set; }
public string Order { get; set; }
public virtual string Order { get; set; }
[YamlMember(Alias = "multi_part", ApplyNamingConventions = false)]
public bool MultiPart { get; set; }
@@ -21,4 +21,10 @@ public class YamlPlayoutContentMarathonItem : YamlPlayoutContentItem
[YamlMember(Alias = "play_all_items", ApplyNamingConventions = false)]
public bool PlayAllItems { get; set; }
public override string Order
{
get => "none";
set => throw new NotSupportedException();
}
}
@@ -8,4 +8,10 @@ public class YamlPlayoutContentPlaylistItem : YamlPlayoutContentItem
[YamlMember(Alias = "playlist_group", ApplyNamingConventions = false)]
public string PlaylistGroup { get; set; }
public override string Order
{
get => "none";
set => throw new NotSupportedException();
}
}
@@ -2,5 +2,5 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
public class YamlPlayoutCountInstruction : YamlPlayoutInstruction
{
public int Count { get; set; }
public string Count { get; set; }
}
@@ -15,4 +15,7 @@ public class YamlPlayoutDurationInstruction : YamlPlayoutInstruction
[YamlMember(Alias = "discard_attempts", ApplyNamingConventions = false)]
public int DiscardAttempts { get; set; }
[YamlMember(Alias = "stop_before_end", ApplyNamingConventions = false)]
public bool StopBeforeEnd { get; set; } = true;
}
@@ -7,12 +7,18 @@ public class YamlPlayoutPadUntilInstruction : YamlPlayoutInstruction
[YamlMember(Alias = "pad_until", ApplyNamingConventions = false)]
public string PadUntil { get; set; }
public bool Tomorrow { get; set; }
public string Tomorrow { get; set; }
public bool Trim { get; set; }
[YamlMember(Alias = "offline_tail", ApplyNamingConventions = false)]
public bool OfflineTail { get; set; }
public string Fallback { get; set; }
[YamlMember(Alias = "discard_attempts", ApplyNamingConventions = false)]
public int DiscardAttempts { get; set; }
[YamlMember(Alias = "stop_before_end", ApplyNamingConventions = false)]
public bool StopBeforeEnd { get; set; } = true;
}
@@ -5,5 +5,5 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling.Models;
public class YamlPlayoutSkipItemsInstruction : YamlPlayoutInstruction
{
[YamlMember(Alias = "skip_items", ApplyNamingConventions = false)]
public int SkipItems { get; set; }
public string SkipItems { get; set; }
}
@@ -0,0 +1,9 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public record YamlMarathonContentResult(
IMediaCollectionEnumerator PlaylistEnumerator,
ImmutableDictionary<CollectionKey, List<MediaItem>> Content);
@@ -357,7 +357,7 @@ public class YamlPlayoutBuilder(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error loading YAML");
logger.LogWarning(ex, "Error loading YAML playout definition");
throw;
}
}
@@ -379,16 +379,23 @@ public class YamlPlayoutBuilder(
foreach ((string _, List<PlayoutHistory> group) in groups)
{
//logger.LogDebug("History key {Key} has {Count} items in group", key, group.Count);
// keep the most recent item from each history group that has already been played completely
IEnumerable<PlayoutHistory> toDelete = group
Option<DateTime> whenToKeep = group
.Filter(h => h.Finish < start.UtcDateTime)
.OrderByDescending(h => h.When)
.Tail();
.Map(h => h.When)
.HeadOrNone();
foreach (PlayoutHistory delete in toDelete)
foreach (DateTime toKeep in whenToKeep)
{
playout.PlayoutHistory.Remove(delete);
// keep the most recent item from each history group that has already been played completely
IEnumerable<PlayoutHistory> toDelete = group
.Filter(h => h.Finish < start.UtcDateTime)
.Filter(h => h.When != toKeep);
foreach (PlayoutHistory delete in toDelete)
{
playout.PlayoutHistory.Remove(delete);
}
}
}
}
@@ -1,14 +1,14 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectionRepository)
{
public async Task<Option<IMediaCollectionEnumerator>> GetEnumerator(
public async Task<Option<YamlMarathonContentResult>> GetEnumerator(
YamlPlayoutContentMarathonItem marathon,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
@@ -64,12 +64,16 @@ public class YamlPlayoutMarathonHelper(IMediaCollectionRepository mediaCollectio
itemMap.Add(playlistItem, group.ToList());
}
return await PlaylistEnumerator.Create(
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
mediaCollectionRepository,
itemMap,
state,
marathon.ShuffleGroups,
cancellationToken);
return new YamlMarathonContentResult(
enumerator,
itemMap.ToImmutableDictionary(x => CollectionKey.ForPlaylistItem(x.Key), x => x.Value));
}
private static GroupKey MediaItemKeyByShow(MediaItem mediaItem) =>
@@ -135,7 +135,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
public async Task<QsvOutput> GetQsvOutput(string ffmpegPath, Option<string> qsvDevice)
{
var option = new QsvHardwareAccelerationOption(qsvDevice);
var option = new QsvHardwareAccelerationOption(qsvDevice, FFmpegCapability.Software);
var arguments = option.GlobalOptions.ToList();
arguments.AddRange(QsvArguments);
+4
View File
@@ -12,6 +12,10 @@ public record ColorParams(string ColorRange, string ColorSpace, string ColorTran
string.IsNullOrWhiteSpace(ColorTransfer) &&
string.IsNullOrWhiteSpace(ColorPrimaries);
public bool IsPartiallyUnknown => string.IsNullOrWhiteSpace(ColorSpace) ||
string.IsNullOrWhiteSpace(ColorTransfer) ||
string.IsNullOrWhiteSpace(ColorPrimaries);
public bool IsMixed => ColorSpace != ColorTransfer || ColorTransfer != ColorPrimaries ||
string.IsNullOrWhiteSpace(ColorRange);
@@ -0,0 +1,15 @@
namespace ErsatzTV.FFmpeg.Environment;
public class CudaVisibleDevicesVariable(string visibleDevices) : IPipelineStep
{
public EnvironmentVariable[] EnvironmentVariables =>
[
new("CUDA_VISIBLE_DEVICES", visibleDevices)
];
public string[] GlobalOptions => [];
public string[] InputOptions(InputFile inputFile) => [];
public string[] FilterOptions => [];
public string[] OutputOptions => [];
public FrameState NextState(FrameState currentState) => currentState;
}
@@ -11,11 +11,9 @@ public class HardwareUploadQsvFilter : BaseFilter
_ffmpegState = ffmpegState;
}
public override string Filter => _currentState.FrameDataLocation switch
{
FrameDataLocation.Hardware => string.Empty,
_ => $"hwupload=extra_hw_frames={_ffmpegState.QsvExtraHardwareFrames}"
};
public override string Filter => _currentState.FrameDataLocation is FrameDataLocation.Software
? $"hwupload=extra_hw_frames={_ffmpegState.QsvExtraHardwareFrames},format=qsv"
: string.Empty;
public override FrameState NextState(FrameState currentState) =>
currentState with { FrameDataLocation = FrameDataLocation.Hardware };

Some files were not shown because too many files have changed in this diff Show More