Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda0318868 | ||
|
|
ac413f731a | ||
|
|
5a9d27e196 | ||
|
|
cd4a9c1d16 | ||
|
|
f6249d9fa4 | ||
|
|
e2ffa70529 | ||
|
|
3e07bc6136 | ||
|
|
d6bfc2fd05 | ||
|
|
35116c64cd | ||
|
|
037cee873f | ||
|
|
cd28afcd91 | ||
|
|
7457301d3e | ||
|
|
7b7d378df7 | ||
|
|
f6dcaf9108 | ||
|
|
6cc2f1de17 | ||
|
|
c6ee41484e | ||
|
|
36d38c740f | ||
|
|
0f795e4e2f | ||
|
|
583cbf7b14 | ||
|
|
27c701b936 | ||
|
|
6e2c19d354 | ||
|
|
4d83dc019c | ||
|
|
462057a4b1 | ||
|
|
a04c72788f | ||
|
|
f94a440b62 | ||
|
|
f80069bb97 | ||
|
|
c2769a08b4 | ||
|
|
e679fee940 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+7
-7
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+100
-17
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user