Compare commits
76 Commits
v0.8.7-bet
...
v0.8.8-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9fc5004f | ||
|
|
f40eaef898 | ||
|
|
91e85cc9c1 | ||
|
|
2c44efb971 | ||
|
|
c2b7be66af | ||
|
|
8b911332a6 | ||
|
|
4130f7316c | ||
|
|
3f6eb5a121 | ||
|
|
1209c54eb9 | ||
|
|
94db4bf679 | ||
|
|
2977590a14 | ||
|
|
b4c168e85e | ||
|
|
55b7a35689 | ||
|
|
a24592a8c4 | ||
|
|
9b60ff0863 | ||
|
|
efdf0bb6d4 | ||
|
|
39ca27cb3d | ||
|
|
9e2f7b7815 | ||
|
|
101d46e283 | ||
|
|
521e4eac41 | ||
|
|
894fc284b2 | ||
|
|
a8cf22e43e | ||
|
|
4c9c047530 | ||
|
|
912f79097d | ||
|
|
8aa55fdfce | ||
|
|
8dc1cab222 | ||
|
|
961fe8bbf2 | ||
|
|
75f991d670 | ||
|
|
e3c981004b | ||
|
|
befaa037e2 | ||
|
|
5e0fb31069 | ||
|
|
7d83e66ba6 | ||
|
|
391528cd94 | ||
|
|
b737775f9a | ||
|
|
728c5130b5 | ||
|
|
e4253276e0 | ||
|
|
1fc55bc693 | ||
|
|
4ad22e402f | ||
|
|
ec99d5976d | ||
|
|
59f11f1a1a | ||
|
|
694f25f8b3 | ||
|
|
5947555e86 | ||
|
|
fb63116b36 | ||
|
|
56a58d7a84 | ||
|
|
6f66909957 | ||
|
|
01090f62e6 | ||
|
|
e4e4f68eb4 | ||
|
|
8488fe5d3d | ||
|
|
f06ef5262a | ||
|
|
ae6bcc4933 | ||
|
|
b83fe53ef1 | ||
|
|
d50f2ace07 | ||
|
|
23684f607a | ||
|
|
fa20c5e01e | ||
|
|
53bd745678 | ||
|
|
f3e5a4e7d8 | ||
|
|
0b29bb32b1 | ||
|
|
d9a7615cf6 | ||
|
|
50f2cb7a33 | ||
|
|
b1b2c2a1e0 | ||
|
|
d842cd57f6 | ||
|
|
4f393d7b06 | ||
|
|
46f7289db8 | ||
|
|
80ccbbf299 | ||
|
|
3765894cb7 | ||
|
|
a8b658a5ea | ||
|
|
0e3c32bd83 | ||
|
|
9dd4a85bf9 | ||
|
|
a0a047ba18 | ||
|
|
687a4f4f10 | ||
|
|
b91ab5d898 | ||
|
|
256042947d | ||
|
|
85029cbbcd | ||
|
|
b5d679212d | ||
|
|
36e86587ef | ||
|
|
f41fa669be |
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
*.cs text diff=csharp
|
||||
*.cshtml text diff=html
|
||||
*.csx text diff=csharp
|
||||
*.sln text eol=crlf
|
||||
*.csproj text eol=crlf
|
||||
18
.github/workflows/artifacts.yml
vendored
18
.github/workflows/artifacts.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-11
|
||||
- os: macos-12
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-11
|
||||
- os: macos-12
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -48,8 +48,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -87,7 +85,7 @@ jobs:
|
||||
- name: Sign
|
||||
shell: bash
|
||||
run: scripts/macOS/sign.sh
|
||||
|
||||
|
||||
- name: Create DMG
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -102,6 +100,7 @@ jobs:
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
--no-internet-enable \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
@@ -164,8 +163,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -190,8 +187,11 @@ jobs:
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
|
||||
# Build Windows launcher
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
|
||||
1
.github/workflows/code_quality.yml
vendored
1
.github/workflows/code_quality.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: Qodana
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
8
.github/workflows/pr.yml
vendored
8
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -40,8 +38,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -58,7 +54,7 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-11
|
||||
runs-on: macos-12
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
@@ -68,8 +64,6 @@ jobs:
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -5,6 +5,87 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.8-beta] - 2024-09-19
|
||||
### Added
|
||||
- Add support for Plex Other Video libraries
|
||||
- These libraries will now appear as ETV Other Video libraries
|
||||
- Items in these libraries will have tag metadata added from folders just like local Other Video libraries
|
||||
- Thanks @raknam for adding this feature!
|
||||
- Add *experimental* support for `On Demand` channel progress
|
||||
- With `On Demand` channel progress, the playout will only advance when the channel is being streamed
|
||||
- When the channel is idle, the playout is unmodified and will be shifted forward as needed so no content is missed
|
||||
- Setting a channel to `On Demand` progress will disable alternate schedules
|
||||
- The `On Demand` setting will only be used for `Flood` playouts (NOT `Block` or `External JSON`)
|
||||
- It is NOT recommended to use fixed start times with `On Demand` progress
|
||||
- This will probably be disabled with a future update
|
||||
- Add `Default Filler` to `Deco` system
|
||||
- After all blocks are scheduled/added to the playout, a second pass will be made to insert filler
|
||||
- Default filler will be shuffled and inserted in all unscheduled time between blocks
|
||||
- Default filler will stop scheduling when the next item would extend into primary content
|
||||
- Alternatively, default filler can be configured to `Trim To Fit`
|
||||
- In this case, the last item that would extend into primary content is trimmed to end exactly when the primary content starts
|
||||
- Add **experimental** playout type `YAML`
|
||||
- This playout type uses a YAML file to declare content and describe how the playout should be built
|
||||
- Content currently supports search queries
|
||||
- Playout instructions currently include `count`, `pad to next`, and `repeat`
|
||||
- `count`: add the specified number of items (from the referenced content) to the playout
|
||||
- `duration`: play the referenced content for the specified duration
|
||||
- `pad to next`: add items from the referenced content until the wall clock is a multiple of the specified minutes value
|
||||
- `repeat`: continue building the playout from the first instruction in the YAML file
|
||||
- Add channel logo generation by @raknam
|
||||
- Channels without custom uploaded logos will automatically generate a logo that includes the channel name
|
||||
- Add two new API endpoints
|
||||
- Reset playout for channel
|
||||
- POST `/api/channels/{channelNumber}/playout/reset`
|
||||
- Scan library
|
||||
- POST `/api/libraries/{libraryId}/scan`
|
||||
- Add Deco setting to `Use Watermark During Filler`
|
||||
- This setting is turned OFF by default, meaning filler will NOT use the configured watermark unless this is manually turned on
|
||||
- Add `Random Count` filler mode by @embolon
|
||||
- This mode will randomly schedule between zero and the provided count number of items
|
||||
- e.g. random count 3 will schedule between 0 and 3 filler items
|
||||
- Add `Random Rotation` playback order for block scheduling by @embolon
|
||||
- This playback order will pick a random item from a randomly selected group (show or artist)
|
||||
- It is somewhat similar to the `Fill With Group` mode used in flood scheduling
|
||||
|
||||
### Fixed
|
||||
- Add basic cache busting to XMLTV image URLs
|
||||
- This should help with clients not showing correct channel logos or posters
|
||||
- Fix artwork in other video libraries by @raknam
|
||||
- Fix adding items to empty playlists
|
||||
- Fix filler preset editor and deco dead air fallback editor to only show supported collection types
|
||||
- Fix infinite loop caused by impossible schedule (all collection items longer than schedule item duration)
|
||||
- Fix selecting audio and subtitle streams with two-letter language codes
|
||||
- Fix adding pad filler to content that is less than one minute in duration
|
||||
- Generate unique identifier for virtual HDHomeRun tuner by @raknam
|
||||
- This allows a single Plex server to connect to multiple ETV instances
|
||||
- Include *all* language codes from media library in preferred audio and subtitle language options
|
||||
- Language codes where an English name cannot be found will be at the bottom of the list
|
||||
- Fix local libraries to detect external subtitle files with unrecognized language codes
|
||||
- Fix playback selection of subtitles with unrecognized language codes
|
||||
- Fix incorrectly removing block items that are hidden from EPG when deco filler is applied
|
||||
- Fix deco selection when deco is scheduled until midnight
|
||||
- Previously, this deco item would be ignored so watermark and filler would be missing
|
||||
- Fix movies with missing medata by generating fallback metadata
|
||||
- This allows these movies to appear in the Trash where they can be deleted
|
||||
- Fix synchronizing trakt lists from users with special characters in their username
|
||||
- Note that these lists MUST be added as URLs; the short-form `user/list` will NOT work with special characters
|
||||
- Fix local subtitle scanner to detect non-lowercase extensions (e.g. `Movie (2000).EN.SRT`)
|
||||
- Fix adding a single image to a manual collection from search results
|
||||
- Fix loading manual collection view when collection contains images
|
||||
- Fix edge case where block playout history would get stuck and repeat an item
|
||||
- Fix adjusting watermark opacity when watermark already contains alpha channel (is already transparent)
|
||||
|
||||
### Changed
|
||||
- Remove some unnecessary API calls related to media server scanning and paging
|
||||
- Improve trakt list URL validation; non-trakt URLs will no longer be requested
|
||||
- Prevent saving block templates when blocks are overlapping
|
||||
- This can happen if block durations are changed for blocks that are already on the template
|
||||
- Redirect variant playlist request to proper URL for starting `HLS Segmenter` session when no session is active
|
||||
- This can happen when some clients "pause" long enough for the session to stop in ETV
|
||||
- When the client resumes playback, it requests the temp playlist URL which is now invalid e.g. `/iptv/session/1/hls.m3u8` (not the original URL `/iptv/channel/1.m3u8`)
|
||||
- To fix, the client will be redirected back to the original URL in this case which will create a new session
|
||||
|
||||
## [0.8.7-beta] - 2024-06-26
|
||||
### Added
|
||||
- Add `Active Date Range` to block playout template editor to allow limiting templates to a specific date range
|
||||
@@ -2050,7 +2131,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.7-beta...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.8-beta...HEAD
|
||||
[0.8.8-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.7-beta...v0.8.8-beta
|
||||
[0.8.7-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.6-beta...v0.8.7-beta
|
||||
[0.8.6-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.5-beta...v0.8.6-beta
|
||||
[0.8.5-beta]: https://github.com/ErsatzTV/ErsatzTV/compare/v0.8.4-beta...v0.8.5-beta
|
||||
|
||||
@@ -24,7 +24,7 @@ public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<Artist
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
|
||||
6
ErsatzTV.Application/Artworks/Queries/GetArtwork.cs
Normal file
6
ErsatzTV.Application/Artworks/Queries/GetArtwork.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
|
||||
42
ErsatzTV.Application/Artworks/Queries/GetArtworkHandler.cs
Normal file
42
ErsatzTV.Application/Artworks/Queries/GetArtworkHandler.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Artwork>> Handle(
|
||||
GetArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try {
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Artwork> artwork = await dbContext.Artwork
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
|
||||
.MapT(Project);
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static Artwork Project(Artwork artwork)
|
||||
{
|
||||
return new Artwork {
|
||||
Id = artwork.Id,
|
||||
Path = artwork.Path,
|
||||
ArtworkKind = artwork.ArtworkKind
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Net;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
@@ -12,6 +13,7 @@ public record ChannelViewModel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
@@ -19,4 +21,7 @@ public record ChannelViewModel(
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate);
|
||||
string MusicVideoCreditsTemplate)
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public record CreateChannel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -39,8 +38,6 @@ public class CreateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
ValidatePreferredAudioLanguage(request),
|
||||
ValidatePreferredSubtitleLanguage(request),
|
||||
await WatermarkMustExist(dbContext, request),
|
||||
await FillerPresetMustExist(dbContext, request))
|
||||
.Apply(
|
||||
@@ -48,8 +45,6 @@ public class CreateChannelHandler(
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
preferredAudioLanguageCode,
|
||||
preferredSubtitleLanguageCode,
|
||||
watermarkId,
|
||||
fillerPresetId) =>
|
||||
{
|
||||
@@ -73,11 +68,12 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
ProgressMode = request.ProgressMode,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
|
||||
@@ -100,20 +96,6 @@ public class CreateChannelHandler(
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredAudioLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredSubtitleLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredSubtitleLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred subtitle language code is invalid");
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
|
||||
@@ -182,6 +182,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
switch (playout.ProgramSchedulePlayoutType)
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Flood:
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
var floodSorted = playouts
|
||||
.Collect(p => p.Items)
|
||||
.OrderBy(pi => pi.Start)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.Net;
|
||||
using System.Xml;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
@@ -81,7 +82,8 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
|
||||
ChannelName = channel.Name,
|
||||
ChannelCategories = GetCategories(channel.Categories),
|
||||
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
|
||||
ChannelArtworkPath = channel.ArtworkPath
|
||||
ChannelArtworkPath = channel.ArtworkPath,
|
||||
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
|
||||
};
|
||||
|
||||
var scriptObject = new ScriptObject();
|
||||
|
||||
@@ -13,6 +13,7 @@ public record UpdateChannel(
|
||||
string Logo,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelProgressMode ProgressMode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
|
||||
@@ -67,6 +67,7 @@ public class UpdateChannelHandler(
|
||||
});
|
||||
}
|
||||
|
||||
c.ProgressMode = update.ProgressMode;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -92,9 +93,8 @@ public class UpdateChannelHandler(
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request),
|
||||
ValidatePreferredAudioLanguage(request))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
await ValidateNumber(dbContext, request))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -129,11 +129,4 @@ public class UpdateChannelHandler(
|
||||
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidatePreferredAudioLanguage(UpdateChannel updateChannel) =>
|
||||
Optional(updateChannel.PreferredAudioLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred audio language code is invalid");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ internal static class Mapper
|
||||
GetLogo(channel),
|
||||
channel.PreferredAudioLanguageCode,
|
||||
channel.PreferredAudioTitle,
|
||||
channel.ProgressMode,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
|
||||
@@ -36,10 +36,12 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
string accessTokenUri = string.Empty;
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri = $"?access_token={request.AccessToken}";
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.6" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.10.48">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
3
ErsatzTV.Application/HDHR/Queries/GetHDHRUUID.cs
Normal file
3
ErsatzTV.Application/HDHR/Queries/GetHDHRUUID.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.HDHR;
|
||||
|
||||
public record GetHDHRUUID : IRequest<Guid>;
|
||||
24
ErsatzTV.Application/HDHR/Queries/GetHDHRUUIDHandler.cs
Normal file
24
ErsatzTV.Application/HDHR/Queries/GetHDHRUUIDHandler.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.HDHR;
|
||||
|
||||
public class GetHDHRUUIDHandler : IRequestHandler<GetHDHRUUID, Guid>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetHDHRUUIDHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Guid> Handle(GetHDHRUUID request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Guid> maybeGuid = await _configElementRepository.GetValue<Guid>(ConfigElementKey.HDHRUUID);
|
||||
return await maybeGuid.IfNoneAsync(
|
||||
async () =>
|
||||
{
|
||||
Guid guid = Guid.NewGuid();
|
||||
await _configElementRepository.Upsert(ConfigElementKey.HDHRUUID, guid);
|
||||
return guid;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public class QueueLibraryScanByLibraryIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IEntityLocker locker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
|
||||
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
|
||||
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
|
||||
{
|
||||
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Library> maybeLibrary = await dbContext.Libraries
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId);
|
||||
|
||||
foreach (Library library in maybeLibrary)
|
||||
{
|
||||
if (locker.LockLibrary(library.Id))
|
||||
{
|
||||
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
|
||||
|
||||
switch (library)
|
||||
{
|
||||
case LocalLibrary:
|
||||
await scannerWorker.WriteAsync(new ForceScanLocalLibrary(library.Id), cancellationToken);
|
||||
break;
|
||||
case PlexLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizePlexLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizePlexLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case JellyfinLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizeJellyfinLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeJellyfinLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
case EmbyLibrary:
|
||||
await scannerWorker.WriteAsync(
|
||||
new SynchronizeEmbyLibraries(library.MediaSourceId),
|
||||
cancellationToken);
|
||||
await scannerWorker.WriteAsync(
|
||||
new ForceSynchronizeEmbyLibraryById(library.Id, false),
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -117,13 +117,22 @@ internal static class Mapper
|
||||
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
||||
localPath);
|
||||
|
||||
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
|
||||
new(
|
||||
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata)
|
||||
{
|
||||
string poster = GetThumbnail(otherVideoMetadata, None, None);
|
||||
if (string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
poster = GetPoster(otherVideoMetadata, None, None);
|
||||
}
|
||||
|
||||
return new OtherVideoCardViewModel(
|
||||
otherVideoMetadata.OtherVideoId,
|
||||
otherVideoMetadata.Title,
|
||||
otherVideoMetadata.OriginalTitle,
|
||||
otherVideoMetadata.SortTitle,
|
||||
poster,
|
||||
otherVideoMetadata.OtherVideo.State);
|
||||
}
|
||||
|
||||
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
|
||||
{
|
||||
|
||||
@@ -7,12 +7,13 @@ public record OtherVideoCardViewModel(
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
MediaItemState State) : MediaCardViewModel(
|
||||
OtherVideoId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
null,
|
||||
Poster,
|
||||
State)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
|
||||
@@ -97,6 +97,12 @@ public class GetCollectionCardsHandler :
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Image).ImageMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Image).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
@@ -48,7 +48,7 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
{ ProgramScheduleItemCollectionType.Image, request.ImageIds }
|
||||
};
|
||||
|
||||
int index = playlist.Items.Max(i => i.Index) + 1;
|
||||
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
|
||||
|
||||
foreach ((ProgramScheduleItemCollectionType collectionType, List<int> ids) in allItems)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
@@ -11,7 +12,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
@@ -47,8 +48,11 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
|
||||
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
|
||||
{
|
||||
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
|
||||
Match match = Regex.Match(request.TraktListUrl, PATTERN);
|
||||
// if we get a url, ensure it's for trakt.tv
|
||||
Match match = Uri.IsWellFormedUriString(request.TraktListUrl, UriKind.Absolute)
|
||||
? UriTraktListRegex().Match(request.TraktListUrl)
|
||||
: ShorthandTraktListRegex().Match(request.TraktListUrl);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
string user = match.Groups[1].Value;
|
||||
@@ -63,14 +67,33 @@ public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktLis
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||
.BindT(list => SaveList(dbContext, list))
|
||||
.BindT(list => SaveListItems(dbContext, list))
|
||||
.BindT(list => MatchListItems(dbContext, list))
|
||||
.MapT(_ => Unit.Default);
|
||||
Logger.LogDebug("Searching for trakt list: {User}/{List}", parameters.User, parameters.List);
|
||||
Either<BaseError, TraktList> maybeList = await TraktApiClient.GetUserList(parameters.User, parameters.List);
|
||||
|
||||
// match list items (and update in search index)
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
maybeList = await SaveList(dbContext, list);
|
||||
}
|
||||
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
maybeList = await SaveListItems(dbContext, list);
|
||||
}
|
||||
|
||||
foreach (TraktList list in maybeList.RightToSeq())
|
||||
{
|
||||
// match list items (and update in search index)
|
||||
maybeList = await MatchListItems(dbContext, list);
|
||||
}
|
||||
|
||||
return maybeList.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private sealed record Parameters(string User, string List);
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex();
|
||||
|
||||
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex ShorthandTraktListRegex();
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ public abstract class TraktCommandBase
|
||||
_searchIndex = searchIndex;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
|
||||
TraktApiClient = traktApiClient;
|
||||
Logger = logger;
|
||||
}
|
||||
|
||||
protected ITraktApiClient TraktApiClient { get; }
|
||||
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
protected static Task<Validation<BaseError, TraktList>>
|
||||
TraktListMustExist(TvContext dbContext, int traktListId) =>
|
||||
dbContext.TraktLists
|
||||
@@ -43,8 +47,10 @@ public abstract class TraktCommandBase
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
|
||||
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
|
||||
|
||||
protected static async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||
{
|
||||
_logger.LogDebug("Saving trakt list to database: {User}/{List}", list.User, list.List);
|
||||
|
||||
Option<TraktList> maybeExisting = await dbContext.TraktLists
|
||||
.Include(l => l.Items)
|
||||
.ThenInclude(i => i.Guids)
|
||||
@@ -72,6 +78,8 @@ public abstract class TraktCommandBase
|
||||
|
||||
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
|
||||
{
|
||||
_logger.LogDebug("Saving trakt list items to database: {User}/{List}", list.User, list.List);
|
||||
|
||||
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
|
||||
await TraktApiClient.GetUserListItems(list.User, list.List);
|
||||
|
||||
@@ -118,6 +126,8 @@ public abstract class TraktCommandBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Matching trakt list items: {User}/{List}", list.User, list.List);
|
||||
|
||||
var ids = new System.Collections.Generic.HashSet<int>();
|
||||
|
||||
foreach (TraktListItem item in list.Items
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems;
|
||||
|
||||
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<LanguageCodeViewModel>>
|
||||
public class GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository)
|
||||
: IRequestHandler<GetAllLanguageCodes, List<LanguageCodeViewModel>>
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task<List<LanguageCodeViewModel>> Handle(
|
||||
GetAllLanguageCodes request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<CultureInfo> cultures = await _mediaItemRepository.GetAllLanguageCodeCultures();
|
||||
return cultures.Map(c => new LanguageCodeViewModel(c.ThreeLetterISOLanguageName, c.EnglishName)).ToList();
|
||||
List<LanguageCodeAndName> languageCodes = await mediaItemRepository.GetAllLanguageCodesAndNames();
|
||||
return languageCodes.Map(c => new LanguageCodeViewModel(c.Code, c.Name)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace ErsatzTV.Application.Playouts;
|
||||
public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
|
||||
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
|
||||
private readonly IYamlPlayoutBuilder _yamlPlayoutBuilder;
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
@@ -31,6 +33,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
IBlockPlayoutBuilder blockPlayoutBuilder,
|
||||
IBlockPlayoutFillerBuilder blockPlayoutFillerBuilder,
|
||||
IYamlPlayoutBuilder yamlPlayoutBuilder,
|
||||
IExternalJsonPlayoutBuilder externalJsonPlayoutBuilder,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IEntityLocker entityLocker,
|
||||
@@ -40,6 +44,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_blockPlayoutBuilder = blockPlayoutBuilder;
|
||||
_blockPlayoutFillerBuilder = blockPlayoutFillerBuilder;
|
||||
_yamlPlayoutBuilder = yamlPlayoutBuilder;
|
||||
_externalJsonPlayoutBuilder = externalJsonPlayoutBuilder;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_entityLocker = entityLocker;
|
||||
@@ -69,6 +75,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
case ProgramSchedulePlayoutType.Block:
|
||||
await _blockPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
await _blockPlayoutFillerBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.Yaml:
|
||||
await _yamlPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
break;
|
||||
case ProgramSchedulePlayoutType.ExternalJson:
|
||||
await _externalJsonPlayoutBuilder.Build(playout, request.Mode, cancellationToken);
|
||||
@@ -154,6 +164,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
BuildPlayout buildPlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Deco)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.Include(p => p.Templates)
|
||||
@@ -161,6 +172,10 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Block)
|
||||
.ThenInclude(b => b.Items)
|
||||
.Include(p => p.Templates)
|
||||
.ThenInclude(t => t.DecoTemplate)
|
||||
.ThenInclude(t => t.Items)
|
||||
.ThenInclude(i => i.Deco)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
|
||||
@@ -37,6 +37,10 @@ public class CreateFloodPlayoutHandler : IRequestHandler<CreateFloodPlayout, Eit
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
if (playout.Channel.ProgressMode is ChannelProgressMode.OnDemand)
|
||||
{
|
||||
await _channel.WriteAsync(new TimeShiftOnDemandPlayout(playout.Channel.Number, DateTimeOffset.Now, false));
|
||||
}
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ public record CreateFloodPlayout(int ChannelId, int ProgramScheduleId)
|
||||
public record CreateBlockPlayout(int ChannelId)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Block);
|
||||
|
||||
public record CreateYamlPlayout(int ChannelId, string TemplateFile)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.Yaml);
|
||||
|
||||
public record CreateExternalJsonPlayout(int ChannelId, string ExternalJsonFile)
|
||||
: CreatePlayout(ChannelId, ProgramSchedulePlayoutType.ExternalJson);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class CreateYamlPlayoutHandler
|
||||
: IRequestHandler<CreateYamlPlayout, Either<BaseError, CreatePlayoutResponse>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public CreateYamlPlayoutHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
|
||||
CreateYamlPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
}
|
||||
|
||||
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||
{
|
||||
await dbContext.Playouts.AddAsync(playout);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset));
|
||||
await _channel.WriteAsync(new RefreshChannelList());
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateYamlPlayout request) =>
|
||||
(await ValidateChannel(dbContext, request), ValidateYamlFile(request), ValidatePlayoutType(request))
|
||||
.Apply(
|
||||
(channel, externalJsonFile, playoutType) => new Playout
|
||||
{
|
||||
ChannelId = channel.Id,
|
||||
TemplateFile = externalJsonFile,
|
||||
ProgramSchedulePlayoutType = playoutType,
|
||||
Seed = new Random().Next()
|
||||
});
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ValidateChannel(
|
||||
TvContext dbContext,
|
||||
CreateYamlPlayout createYamlPlayout) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createYamlPlayout.ChannelId)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
|
||||
.BindT(ChannelMustNotHavePlayouts);
|
||||
|
||||
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
|
||||
Optional(channel.Playouts.Count)
|
||||
.Filter(count => count == 0)
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout");
|
||||
|
||||
private Validation<BaseError, string> ValidateYamlFile(CreateYamlPlayout request)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(request.TemplateFile))
|
||||
{
|
||||
return BaseError.New("YAML file does not exist!");
|
||||
}
|
||||
|
||||
return request.TemplateFile;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(
|
||||
CreateYamlPlayout createYamlPlayout) =>
|
||||
Optional(createYamlPlayout.ProgramSchedulePlayoutType)
|
||||
.Filter(playoutType => playoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.ToValidation<BaseError>("[ProgramSchedulePlayoutType] must be YAML");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record TimeShiftOnDemandPlayout(string ChannelNumber, DateTimeOffset Now, bool Force)
|
||||
: IRequest<Option<BaseError>>, IBackgroundServiceRequest;
|
||||
@@ -0,0 +1,41 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class TimeShiftOnDemandPlayoutHandler(
|
||||
IPlayoutTimeShifter playoutTimeShifter,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<TimeShiftOnDemandPlayout, Option<BaseError>>
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(TimeShiftOnDemandPlayout request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.Anchor)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
playoutTimeShifter.TimeShift(playout, request.Now, request.Force);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,11 @@ public class
|
||||
playout.ProgramSchedulePlayoutType,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.ProgressMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.TemplateFile,
|
||||
playout.ExternalJsonFile,
|
||||
Optional(playout.DailyRebuildTime));
|
||||
playout.DailyRebuildTime);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record UpdateOnDemandCheckpoint(string ChannelNumber, DateTimeOffset Checkpoint)
|
||||
: IRequest;
|
||||
@@ -0,0 +1,62 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class UpdateOnDemandCheckpointHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<UpdateOnDemandCheckpointHandler> logger)
|
||||
: IRequestHandler<UpdateOnDemandCheckpoint>
|
||||
{
|
||||
public async Task Handle(UpdateOnDemandCheckpoint request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
.SelectOneAsync(p => p.Channel.Number, p => p.Channel.Number == request.ChannelNumber);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
if (playout.Channel.ProgressMode is not ChannelProgressMode.OnDemand)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int timeout = await (await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout))
|
||||
.IfNoneAsync(60);
|
||||
|
||||
// don't move checkpoint back in time
|
||||
DateTimeOffset newCheckpoint = request.Checkpoint - TimeSpan.FromSeconds(timeout);
|
||||
if (newCheckpoint > playout.OnDemandCheckpoint)
|
||||
{
|
||||
playout.OnDemandCheckpoint = newCheckpoint;
|
||||
}
|
||||
|
||||
// don't checkpoint before the first item
|
||||
// this could happen if you watch a new playout for less time than the segmenter timeout
|
||||
if (playout.Items.Count > 0)
|
||||
{
|
||||
DateTimeOffset minStart = playout.Items.Min(p => p.StartOffset);
|
||||
if (playout.OnDemandCheckpoint < minStart)
|
||||
{
|
||||
playout.OnDemandCheckpoint = minStart;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Updating on demand checkpoint for channel {Number} - {Name} to {Checkpoint}",
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name,
|
||||
playout.OnDemandCheckpoint);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,11 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
|
||||
playout.ProgramSchedulePlayoutType,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.ProgressMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.TemplateFile,
|
||||
playout.ExternalJsonFile,
|
||||
Optional(playout.DailyRebuildTime));
|
||||
playout.DailyRebuildTime);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class
|
||||
UpdateTemplatePlayoutHandler : IRequestHandler<UpdateYamlPlayout,
|
||||
Either<BaseError, PlayoutNameViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateTemplatePlayoutHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||
UpdateYamlPlayout request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||
}
|
||||
|
||||
private async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout request,
|
||||
Playout playout)
|
||||
{
|
||||
playout.TemplateFile = request.TemplateFile;
|
||||
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new RefreshChannelData(playout.Channel.Number));
|
||||
}
|
||||
|
||||
return new PlayoutNameViewModel(
|
||||
playout.Id,
|
||||
playout.ProgramSchedulePlayoutType,
|
||||
playout.Channel.Name,
|
||||
playout.Channel.Number,
|
||||
playout.Channel.ProgressMode,
|
||||
playout.ProgramSchedule?.Name ?? string.Empty,
|
||||
playout.TemplateFile,
|
||||
playout.ExternalJsonFile,
|
||||
playout.DailyRebuildTime);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout request) =>
|
||||
PlayoutMustExist(dbContext, request);
|
||||
|
||||
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateYamlPlayout updatePlayout) =>
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record UpdateYamlPlayout(int PlayoutId, string TemplateFile)
|
||||
: IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||
@@ -7,6 +7,11 @@ public record PlayoutNameViewModel(
|
||||
ProgramSchedulePlayoutType PlayoutType,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelProgressMode ProgressMode,
|
||||
string ScheduleName,
|
||||
string TemplateFile,
|
||||
string ExternalJsonFile,
|
||||
Option<TimeSpan> DailyRebuildTime);
|
||||
TimeSpan? DbDailyRebuildTime)
|
||||
{
|
||||
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ public class GetAllPlayoutsHandler : IRequestHandler<GetAllPlayouts, List<Playou
|
||||
p.ProgramSchedulePlayoutType,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.Channel.ProgressMode,
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.TemplateFile,
|
||||
p.ExternalJsonFile,
|
||||
Optional(p.DailyRebuildTime)))
|
||||
p.DailyRebuildTime))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
3
ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs
Normal file
3
ErsatzTV.Application/Playouts/Queries/GetPlayoutById.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutById(int PlayoutId) : IRequest<Option<PlayoutNameViewModel>>;
|
||||
@@ -0,0 +1,32 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutById, Option<PlayoutNameViewModel>>
|
||||
{
|
||||
public async Task<Option<PlayoutNameViewModel>> Handle(
|
||||
GetPlayoutById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(
|
||||
p => new PlayoutNameViewModel(
|
||||
p.Id,
|
||||
p.ProgramSchedulePlayoutType,
|
||||
p.Channel.Name,
|
||||
p.Channel.Number,
|
||||
p.Channel.ProgressMode,
|
||||
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
|
||||
p.TemplateFile,
|
||||
p.ExternalJsonFile,
|
||||
p.DailyRebuildTime));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutIdByChannelNumber(string ChannelNumber) : IRequest<Option<int>>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutIdByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetPlayoutIdByChannelNumber, Option<int>>
|
||||
{
|
||||
public async Task<Option<int>> Handle(GetPlayoutIdByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.Map(p => p.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.HeadOrNone());
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,13 @@ public class
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.Key != l.Key) && toRemove.All(r => r.Key != l.Key)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Count != 0)
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
@@ -57,6 +57,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
case PlaybackOrder.Random:
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.SeasonEpisode:
|
||||
case PlaybackOrder.RandomRotation:
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest;
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record EraseBlockPlayoutItems(int PlayoutId) : IRequest;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ErasePlayoutHistory(int PlayoutId) : IRequest;
|
||||
@@ -5,15 +5,17 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class EraseBlockPlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<EraseBlockPlayoutHistory>
|
||||
public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ErasePlayoutHistory>
|
||||
{
|
||||
public async Task Handle(EraseBlockPlayoutHistory request, CancellationToken cancellationToken)
|
||||
public async Task Handle(ErasePlayoutHistory request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Filter(p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block)
|
||||
.Filter(
|
||||
p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block ||
|
||||
p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ErasePlayoutItems(int PlayoutId) : IRequest;
|
||||
@@ -6,17 +6,19 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public class EraseBlockPlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<EraseBlockPlayoutItems>
|
||||
public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ErasePlayoutItems>
|
||||
{
|
||||
public async Task Handle(EraseBlockPlayoutItems request, CancellationToken cancellationToken)
|
||||
public async Task Handle(ErasePlayoutItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.Filter(p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block)
|
||||
.Filter(
|
||||
p => p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Block ||
|
||||
p.ProgramSchedulePlayoutType == ProgramSchedulePlayoutType.Yaml)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
@@ -55,11 +55,58 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
};
|
||||
|
||||
private static Task<Validation<BaseError, Template>> Validate(TvContext dbContext, ReplaceTemplateItems request) =>
|
||||
TemplateMustExist(dbContext, request.TemplateId);
|
||||
TemplateMustExist(dbContext, request.TemplateId)
|
||||
.BindT(template => TemplateItemsMustBeValid(dbContext, template, request));
|
||||
|
||||
private static async Task<Validation<BaseError, Template>> TemplateItemsMustBeValid(
|
||||
TvContext dbContext,
|
||||
Template template,
|
||||
ReplaceTemplateItems request)
|
||||
{
|
||||
var allBlockIds = request.Items.Map(i => i.BlockId).Distinct().ToList();
|
||||
|
||||
Dictionary<int, Block> allBlocks = await dbContext.Blocks
|
||||
.AsNoTracking()
|
||||
.Filter(b => allBlockIds.Contains(b.Id))
|
||||
.ToListAsync()
|
||||
.Map(list => list.ToDictionary(b => b.Id, b => b));
|
||||
|
||||
var allTemplateItems = request.Items.Map(
|
||||
i =>
|
||||
{
|
||||
Block block = allBlocks[i.BlockId];
|
||||
return new BlockTemplateItem(
|
||||
i.BlockId,
|
||||
i.StartTime,
|
||||
i.StartTime + TimeSpan.FromMinutes(block.Minutes));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (BlockTemplateItem item in allTemplateItems)
|
||||
{
|
||||
foreach (BlockTemplateItem otherItem in allTemplateItems)
|
||||
{
|
||||
if (item == otherItem)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.StartTime < otherItem.EndTime && otherItem.StartTime < item.EndTime)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Block from {item.StartTime} to {item.EndTime} intersects block from {otherItem.StartTime} to {otherItem.EndTime}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Template>> TemplateMustExist(TvContext dbContext, int templateId) =>
|
||||
dbContext.Templates
|
||||
.Include(b => b.Items)
|
||||
.SelectOneAsync(b => b.Id, b => b.Id == templateId)
|
||||
.Map(o => o.ToValidation<BaseError>("[TemplateId] does not exist."));
|
||||
|
||||
private sealed record BlockTemplateItem(int BlockId, TimeSpan StartTime, TimeSpan EndTime);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ public record UpdateDeco(
|
||||
string Name,
|
||||
DecoMode WatermarkMode,
|
||||
int? WatermarkId,
|
||||
bool UseWatermarkDuringFiller,
|
||||
DecoMode DefaultFillerMode,
|
||||
ProgramScheduleItemCollectionType DefaultFillerCollectionType,
|
||||
int? DefaultFillerCollectionId,
|
||||
int? DefaultFillerMediaItemId,
|
||||
int? DefaultFillerMultiCollectionId,
|
||||
int? DefaultFillerSmartCollectionId,
|
||||
bool DefaultFillerTrimToFit,
|
||||
DecoMode DeadAirFallbackMode,
|
||||
ProgramScheduleItemCollectionType DeadAirFallbackCollectionType,
|
||||
int? DeadAirFallbackCollectionId,
|
||||
|
||||
@@ -26,6 +26,25 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
// watermark
|
||||
existing.WatermarkMode = request.WatermarkMode;
|
||||
existing.WatermarkId = request.WatermarkMode is DecoMode.Override ? request.WatermarkId : null;
|
||||
existing.UseWatermarkDuringFiller =
|
||||
request.WatermarkMode is DecoMode.Override && request.UseWatermarkDuringFiller;
|
||||
|
||||
// default filler
|
||||
existing.DefaultFillerMode = request.DefaultFillerMode;
|
||||
existing.DefaultFillerCollectionType = request.DefaultFillerCollectionType;
|
||||
existing.DefaultFillerCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerMediaItemId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerMediaItemId
|
||||
: null;
|
||||
existing.DefaultFillerMultiCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerMultiCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerSmartCollectionId = request.DefaultFillerMode is DecoMode.Override
|
||||
? request.DefaultFillerSmartCollectionId
|
||||
: null;
|
||||
existing.DefaultFillerTrimToFit = request.DefaultFillerTrimToFit;
|
||||
|
||||
// dead air fallback
|
||||
existing.DeadAirFallbackMode = request.DeadAirFallbackMode;
|
||||
|
||||
@@ -9,6 +9,14 @@ public record DecoViewModel(
|
||||
string Name,
|
||||
DecoMode WatermarkMode,
|
||||
int? WatermarkId,
|
||||
bool UseWatermarkDuringFiller,
|
||||
DecoMode DefaultFillerMode,
|
||||
ProgramScheduleItemCollectionType DefaultFillerCollectionType,
|
||||
int? DefaultFillerCollectionId,
|
||||
int? DefaultFillerMediaItemId,
|
||||
int? DefaultFillerMultiCollectionId,
|
||||
int? DefaultFillerSmartCollectionId,
|
||||
bool DefaultFillerTrimToFit,
|
||||
DecoMode DeadAirFallbackMode,
|
||||
ProgramScheduleItemCollectionType DeadAirFallbackCollectionType,
|
||||
int? DeadAirFallbackCollectionId,
|
||||
|
||||
@@ -56,6 +56,14 @@ internal static class Mapper
|
||||
deco.Name,
|
||||
deco.WatermarkMode,
|
||||
deco.WatermarkId,
|
||||
deco.UseWatermarkDuringFiller,
|
||||
deco.DefaultFillerMode,
|
||||
deco.DefaultFillerCollectionType,
|
||||
deco.DefaultFillerCollectionId,
|
||||
deco.DefaultFillerMediaItemId,
|
||||
deco.DefaultFillerMultiCollectionId,
|
||||
deco.DefaultFillerSmartCollectionId,
|
||||
deco.DefaultFillerTrimToFit,
|
||||
deco.DeadAirFallbackMode,
|
||||
deco.DeadAirFallbackCollectionType,
|
||||
deco.DeadAirFallbackCollectionId,
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Timers;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -172,6 +173,9 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
PlaylistStart = _transcodedUntil;
|
||||
|
||||
// time shift on-demand playout if needed
|
||||
await _mediator.Send(new TimeShiftOnDemandPlayout(_channelNumber, _transcodedUntil, true), cancellationToken);
|
||||
|
||||
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
|
||||
_state = initialWorkAhead ? HlsSessionState.SeekAndWorkAhead : HlsSessionState.SeekAndRealtime;
|
||||
|
||||
@@ -236,7 +240,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,6 +528,17 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mediator.Send(
|
||||
new UpdateOnDemandCheckpoint(_channelNumber, DateTimeOffset.Now),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
if (!realtime)
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using System.Timers;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -124,6 +125,9 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
PlaylistStart = _transcodedUntil;
|
||||
|
||||
// time shift on-demand playout if needed
|
||||
await _mediator.Send(new TimeShiftOnDemandPlayout(_channelNumber, _transcodedUntil, true), cancellationToken);
|
||||
|
||||
// start concat/segmenter process
|
||||
// other transcode processes will be started by incoming requests from concat/segmenter process
|
||||
|
||||
@@ -171,6 +175,17 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
|
||||
_timer.Elapsed -= CancelRun;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _mediator.Send(
|
||||
new UpdateOnDemandCheckpoint(_channelNumber, DateTimeOffset.Now),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_localFileSystem.EmptyFolder(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber));
|
||||
@@ -192,7 +207,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
Option<ChannelWatermark> playoutItemWatermark = Optional(playoutItemWithPath.PlayoutItem.Watermark);
|
||||
bool disableWatermarks = playoutItemWithPath.PlayoutItem.DisableWatermarks;
|
||||
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem.Playout, now);
|
||||
WatermarkResult watermarkResult = GetPlayoutItemWatermark(playoutItemWithPath.PlayoutItem, now);
|
||||
switch (watermarkResult)
|
||||
{
|
||||
case InheritWatermark:
|
||||
@@ -464,7 +464,10 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
case CustomDeadAirFallback custom:
|
||||
maybeFallback = new FillerPreset
|
||||
{
|
||||
AllowWatermarks = false, // TODO: does this need to be configurable?
|
||||
// always allow watermarks here
|
||||
// deco settings will disable watermarks if appropriate
|
||||
AllowWatermarks = true,
|
||||
|
||||
CollectionType = custom.CollectionType,
|
||||
CollectionId = custom.CollectionId,
|
||||
MediaItemId = custom.MediaItemId,
|
||||
@@ -657,9 +660,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
};
|
||||
}
|
||||
|
||||
private WatermarkResult GetPlayoutItemWatermark(Playout playout, DateTimeOffset now)
|
||||
private WatermarkResult GetPlayoutItemWatermark(PlayoutItem playoutItem, DateTimeOffset now)
|
||||
{
|
||||
DecoEntries decoEntries = GetDecoEntries(playout, now);
|
||||
DecoEntries decoEntries = GetDecoEntries(playoutItem.Playout, now);
|
||||
|
||||
// first, check deco template / active deco
|
||||
foreach (Deco templateDeco in decoEntries.TemplateDeco)
|
||||
@@ -667,8 +670,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
switch (templateDeco.WatermarkMode)
|
||||
{
|
||||
case DecoMode.Override:
|
||||
_logger.LogDebug("Watermark will come from template deco (override)");
|
||||
return new CustomWatermark(templateDeco.Watermark);
|
||||
if (playoutItem.FillerKind is FillerKind.None || templateDeco.UseWatermarkDuringFiller)
|
||||
{
|
||||
_logger.LogDebug("Watermark will come from template deco (override)");
|
||||
return new CustomWatermark(templateDeco.Watermark);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Watermark is disabled by template deco during filler");
|
||||
return new DisableWatermark();
|
||||
case DecoMode.Disable:
|
||||
_logger.LogDebug("Watermark is disabled by template deco");
|
||||
return new DisableWatermark();
|
||||
@@ -684,8 +693,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
switch (playoutDeco.WatermarkMode)
|
||||
{
|
||||
case DecoMode.Override:
|
||||
_logger.LogDebug("Watermark will come from playout deco (override)");
|
||||
return new CustomWatermark(playoutDeco.Watermark);
|
||||
if (playoutItem.FillerKind is FillerKind.None || playoutDeco.UseWatermarkDuringFiller)
|
||||
{
|
||||
_logger.LogDebug("Watermark will come from playout deco (override)");
|
||||
return new CustomWatermark(playoutDeco.Watermark);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Watermark is disabled by playout deco during filler");
|
||||
return new DisableWatermark();
|
||||
case DecoMode.Disable:
|
||||
_logger.LogDebug("Watermark is disabled by playout deco");
|
||||
return new DisableWatermark();
|
||||
@@ -766,7 +781,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
{
|
||||
Option<DecoTemplateItem> maybeItem = Optional(activeTemplate.DecoTemplate)
|
||||
.SelectMany(dt => dt.Items)
|
||||
.Find(i => i.StartTime <= now.TimeOfDay && i.EndTime > now.TimeOfDay);
|
||||
.Find(i => i.StartTime <= now.TimeOfDay && i.EndTime == TimeSpan.Zero || i.EndTime > now.TimeOfDay);
|
||||
foreach (DecoTemplateItem item in maybeItem)
|
||||
{
|
||||
maybeTemplateDeco = Optional(item.Deco);
|
||||
|
||||
@@ -35,7 +35,7 @@ public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowByI
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForShow(show);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
||||
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby);
|
||||
},
|
||||
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
|
||||
|
||||
@@ -9,24 +9,24 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.6" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.8" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.10.48">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="NUnit" Version="4.1.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="NUnit" Version="4.2.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
121
ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
Normal file
121
ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Scripting;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class FFmpegStreamSelectorTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class SelectAudioStream
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Select_Audio_Stream_With_Preferred_Language()
|
||||
{
|
||||
// skip movie/episode script paths by using other video
|
||||
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 = 6,
|
||||
Language = "eng",
|
||||
Title = "Another Title",
|
||||
Default = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var audioVersion = new MediaItemAudioVersion(mediaItem, mediaVersion);
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
PreferredAudioLanguageCode = "eng"
|
||||
};
|
||||
|
||||
ISearchRepository searchRepository = Substitute.For<ISearchRepository>();
|
||||
searchRepository.GetAllThreeLetterLanguageCodes(Arg.Any<List<string>>())
|
||||
.Returns(Task.FromResult(new List<string> { "jpn" }));
|
||||
|
||||
var selector = new FFmpegStreamSelector(
|
||||
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
|
||||
Substitute.For<IStreamSelectorRepository>(),
|
||||
searchRepository,
|
||||
Substitute.For<IConfigElementRepository>(),
|
||||
Substitute.For<ILocalFileSystem>(),
|
||||
Substitute.For<ILogger<FFmpegStreamSelector>>());
|
||||
|
||||
Option<MediaStream> selectedStream = await selector.SelectAudioStream(audioVersion, StreamingMode.TransportStream, channel, "jpn", "Whatever");
|
||||
selectedStream.IsSome.Should().BeTrue();
|
||||
foreach (MediaStream stream in selectedStream)
|
||||
{
|
||||
stream.Language.Should().Be("ja");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_Subtitle_Stream_With_Preferred_Language()
|
||||
{
|
||||
// skip movie/episode script paths by using other video
|
||||
var subtitles = new List<Subtitle>
|
||||
{
|
||||
new()
|
||||
{
|
||||
StreamIndex = 0,
|
||||
SubtitleKind = SubtitleKind.Sidecar,
|
||||
Language = "eng",
|
||||
Default = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
StreamIndex = 1,
|
||||
SubtitleKind = SubtitleKind.Sidecar,
|
||||
Language = "he",
|
||||
},
|
||||
};
|
||||
|
||||
var channel = new Channel(Guid.NewGuid());
|
||||
|
||||
ISearchRepository searchRepository = Substitute.For<ISearchRepository>();
|
||||
searchRepository.GetAllThreeLetterLanguageCodes(Arg.Any<List<string>>())
|
||||
.Returns(Task.FromResult(new List<string> { "heb" }));
|
||||
|
||||
var selector = new FFmpegStreamSelector(
|
||||
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
|
||||
Substitute.For<IStreamSelectorRepository>(),
|
||||
searchRepository,
|
||||
Substitute.For<IConfigElementRepository>(),
|
||||
Substitute.For<ILocalFileSystem>(),
|
||||
Substitute.For<ILogger<FFmpegStreamSelector>>());
|
||||
|
||||
Option<Subtitle> selectedStream = await selector.SelectSubtitleStream(
|
||||
subtitles,
|
||||
channel,
|
||||
"heb",
|
||||
ChannelSubtitleMode.Any);
|
||||
selectedStream.IsSome.Should().BeTrue();
|
||||
foreach (Subtitle stream in selectedStream)
|
||||
{
|
||||
stream.Language.Should().Be("he");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(int playlistId) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(string groupName, string name) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(Playlist playlist) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -20,8 +23,13 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetCollectionItemsByName(string name) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItemsByName(string name) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItemsByName(string name) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(string query) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetPlaylistItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<Movie>> GetMovie(int id) => throw new NotSupportedException();
|
||||
public Task<List<Episode>> GetEpisode(int id) => throw new NotSupportedException();
|
||||
|
||||
@@ -60,6 +60,7 @@ public class PlaylistEnumeratorTests
|
||||
repo,
|
||||
playlistItemMap,
|
||||
new CollectionEnumeratorState(),
|
||||
shufflePlaylistItems: false,
|
||||
CancellationToken.None);
|
||||
|
||||
enumerator.MoveNext();
|
||||
@@ -123,6 +124,7 @@ public class PlaylistEnumeratorTests
|
||||
repo,
|
||||
playlistItemMap,
|
||||
new CollectionEnumeratorState(),
|
||||
shufflePlaylistItems: false,
|
||||
CancellationToken.None);
|
||||
|
||||
enumerator.MoveNext();
|
||||
|
||||
@@ -558,6 +558,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -657,6 +658,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -804,6 +806,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -909,6 +912,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1023,6 +1027,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1130,6 +1135,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1241,6 +1247,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1357,6 +1364,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1462,6 +1470,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1578,6 +1587,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1705,6 +1715,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1824,6 +1835,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -1903,6 +1915,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -2118,6 +2131,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -2609,6 +2623,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -2723,6 +2738,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -2837,6 +2853,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
@@ -2946,6 +2963,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
@@ -3001,6 +3019,7 @@ public class PlayoutBuilderTests
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
configRepo,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
|
||||
@@ -117,6 +117,7 @@ public class ScheduleIntegrationTests
|
||||
provider.GetRequiredService<IFallbackMetadataProvider>());
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(Substitute.For<IClient>(), searchIndex, factory),
|
||||
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
@@ -287,6 +288,7 @@ public class ScheduleIntegrationTests
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
Substitute.For<IPlayoutTimeShifter>(),
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(Substitute.For<IClient>(), Substitute.For<ISearchIndex>(), factory),
|
||||
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using System.Net;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -28,4 +29,6 @@ public class Channel
|
||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||
public string MusicVideoCreditsTemplate { get; set; }
|
||||
public ChannelProgressMode ProgressMode { get; set; }
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
}
|
||||
|
||||
7
ErsatzTV.Core/Domain/ChannelProgressMode.cs
Normal file
7
ErsatzTV.Core/Domain/ChannelProgressMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum ChannelProgressMode
|
||||
{
|
||||
Always = 0,
|
||||
OnDemand = 1
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ConfigElementKey
|
||||
{
|
||||
@@ -27,6 +27,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey FFmpegHlsDirectOutputFormat => new("ffmpeg.hls_direct.output_format");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
|
||||
|
||||
@@ -5,5 +5,6 @@ public enum FillerMode
|
||||
None = 0,
|
||||
Duration = 1,
|
||||
Count = 2,
|
||||
Pad = 3
|
||||
Pad = 3,
|
||||
RandomCount = 4
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MediaItem
|
||||
public abstract class MediaItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int LibraryPathId { get; set; }
|
||||
|
||||
7
ErsatzTV.Core/Domain/MediaItem/PlexOtherVideo.cs
Normal file
7
ErsatzTV.Core/Domain/MediaItem/PlexOtherVideo.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class PlexOtherVideo : OtherVideo
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum PlaybackOrder
|
||||
Shuffle = 3,
|
||||
ShuffleInOrder = 4,
|
||||
MultiEpisodeShuffle = 5,
|
||||
SeasonEpisode = 6
|
||||
SeasonEpisode = 6,
|
||||
RandomRotation = 7
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public class Playout
|
||||
public int? ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public string ExternalJsonFile { get; set; }
|
||||
public string TemplateFile { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
@@ -22,4 +23,5 @@ public class Playout
|
||||
public TimeSpan? DailyRebuildTime { get; set; }
|
||||
public int? DecoId { get; set; }
|
||||
public Deco Deco { get; set; }
|
||||
public DateTimeOffset? OnDemandCheckpoint { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public class PlayoutAnchor
|
||||
public bool InFlood { get; set; }
|
||||
public bool InDurationFiller { get; set; }
|
||||
public int NextGuideGroup { get; set; }
|
||||
public int NextInstructionIndex { get; set; }
|
||||
|
||||
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ public enum ProgramSchedulePlayoutType
|
||||
None = 0,
|
||||
Flood = 1,
|
||||
Block = 2,
|
||||
Yaml = 3,
|
||||
|
||||
ExternalJson = 20
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ public class Deco
|
||||
public DecoMode WatermarkMode { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public bool UseWatermarkDuringFiller { get; set; }
|
||||
|
||||
// default filler
|
||||
public DecoMode DefaultFillerMode { get; set; }
|
||||
public ProgramScheduleItemCollectionType DefaultFillerCollectionType { get; set; }
|
||||
public int? DefaultFillerCollectionId { get; set; }
|
||||
public Collection DefaultFillerCollection { get; set; }
|
||||
public int? DefaultFillerMediaItemId { get; set; }
|
||||
public MediaItem DefaultFillerMediaItem { get; set; }
|
||||
public int? DefaultFillerMultiCollectionId { get; set; }
|
||||
public MultiCollection DefaultFillerMultiCollection { get; set; }
|
||||
public int? DefaultFillerSmartCollectionId { get; set; }
|
||||
public SmartCollection DefaultFillerSmartCollection { get; set; }
|
||||
public bool DefaultFillerTrimToFit { get; set; }
|
||||
|
||||
// dead air fallback
|
||||
public DecoMode DeadAirFallbackMode { get; set; }
|
||||
|
||||
@@ -7,17 +7,20 @@ public class PlayoutHistory
|
||||
public int PlayoutId { get; set; }
|
||||
public Playout Playout { get; set; }
|
||||
|
||||
public int BlockId { get; set; }
|
||||
public int? BlockId { get; set; }
|
||||
public Block Block { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
public int Index { get; set; }
|
||||
|
||||
// something that uniquely identifies the collection within the block
|
||||
// something that uniquely identifies the collection within the block
|
||||
public string Key { get; set; }
|
||||
|
||||
// last occurence of an item from this collection in the playout
|
||||
public DateTime When { get; set; }
|
||||
|
||||
// used to efficiently ignore/remove "still active" history items
|
||||
public DateTime Finish { get; set; }
|
||||
|
||||
// details about the item
|
||||
public string Details { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public static class EmbyItemType
|
||||
{
|
||||
public static readonly string Movie = "Movie";
|
||||
public static readonly string Show = "Series";
|
||||
public static readonly string Season = "Season";
|
||||
public static readonly string Episode = "Episode";
|
||||
public static readonly string Collection = "BoxSet";
|
||||
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
|
||||
}
|
||||
@@ -12,21 +12,25 @@
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="4.0.0" />
|
||||
<PackageReference Include="Flurl" Version="4.0.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.8" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.10.48">
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.8" />
|
||||
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
23
ErsatzTV.Core/Extensions/StringExtensions.cs
Normal file
23
ErsatzTV.Core/Extensions/StringExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ErsatzTV.Core.Extensions;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static int GetStableHashCode(this string str)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash1 = 5381;
|
||||
int hash2 = hash1;
|
||||
|
||||
for (int i = 0; i < str.Length && str[i] != '\0'; i += 2)
|
||||
{
|
||||
hash1 = ((hash1 << 5) + hash1) ^ str[i];
|
||||
if (i == str.Length - 1 || str[i + 1] == '\0')
|
||||
break;
|
||||
hash2 = ((hash2 << 5) + hash2) ^ str[i + 1];
|
||||
}
|
||||
|
||||
return hash1 + (hash2 * 1566083941);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
@@ -188,10 +190,15 @@ public class FFmpegProcessService
|
||||
None,
|
||||
await IsAnimated(ffprobePath, customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
Option<string> maybeChannelPath = (channel.Artwork.Count == 0) ?
|
||||
//We have to generate the logo on the fly and save it to a local temp path
|
||||
ChannelLogoGenerator.GenerateChannelLogoUrl(channel) :
|
||||
//We have an artwork attached to the channel, let's use it :)
|
||||
channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(watermark),
|
||||
maybeChannelPath,
|
||||
@@ -220,10 +227,14 @@ public class FFmpegProcessService
|
||||
None,
|
||||
await IsAnimated(ffprobePath, customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
Option<string> maybeChannelPath = (channel.Artwork.Count == 0) ?
|
||||
//We have to generate the logo on the fly and save it to a local temp path
|
||||
ChannelLogoGenerator.GenerateChannelLogoUrl(channel) :
|
||||
//We have an artwork attached to the channel, let's use it :)
|
||||
channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(channel.Watermark),
|
||||
maybeChannelPath,
|
||||
@@ -252,10 +263,14 @@ public class FFmpegProcessService
|
||||
None,
|
||||
await IsAnimated(ffprobePath, customPath));
|
||||
case ChannelWatermarkImageSource.ChannelLogo:
|
||||
Option<string> maybeChannelPath = channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
Option<string> maybeChannelPath = (channel.Artwork.Count == 0) ?
|
||||
//We have to generate the logo on the fly and save it to a local temp path
|
||||
ChannelLogoGenerator.GenerateChannelLogoUrl(channel) :
|
||||
//We have an artwork attached to the channel, let's use it :)
|
||||
channel.Artwork
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
.Map(a => _imageCache.GetPathForImage(a.Path, ArtworkKind.Logo, Option<int>.None));
|
||||
return new WatermarkOptions(
|
||||
await watermarkOverride.IfNoneAsync(watermark),
|
||||
maybeChannelPath,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -69,7 +70,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
});
|
||||
}
|
||||
|
||||
List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
|
||||
List<string> allLanguageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes([language])
|
||||
.Map(GetTwoAndThreeLetterLanguageCodes);
|
||||
if (allLanguageCodes.Count > 1)
|
||||
{
|
||||
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allLanguageCodes);
|
||||
@@ -178,7 +180,8 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
else
|
||||
{
|
||||
// filter to preferred language
|
||||
allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
|
||||
allCodes = await _searchRepository.GetAllThreeLetterLanguageCodes([language])
|
||||
.Map(GetTwoAndThreeLetterLanguageCodes);
|
||||
if (allCodes.Count > 1)
|
||||
{
|
||||
_logger.LogDebug("Preferred subtitle language has multiple codes {Codes}", allCodes);
|
||||
@@ -402,6 +405,26 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
return Option<MediaStream>.None;
|
||||
}
|
||||
|
||||
private static List<string> GetTwoAndThreeLetterLanguageCodes(List<string> threeLetterLanguageCodes)
|
||||
{
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
var result = new System.Collections.Generic.HashSet<string>(threeLetterLanguageCodes);
|
||||
|
||||
foreach (string code in threeLetterLanguageCodes)
|
||||
{
|
||||
IEnumerable<CultureInfo> cultures = allCultures
|
||||
.Filter(ci => string.Equals(ci.ThreeLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (CultureInfo culture in cultures)
|
||||
{
|
||||
result.Add(culture.ThreeLetterISOLanguageName);
|
||||
result.Add(culture.TwoLetterISOLanguageName);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToList();
|
||||
}
|
||||
|
||||
private static AudioStream[] GetAudioStreamsForScript(MediaVersion version) => version.Streams
|
||||
.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio)
|
||||
.Map(a => new AudioStream(a.Index, a.Channels, a.Codec, a.Default, a.Forced, a.Language, a.Title))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ErsatzTV.Core.Hdhr;
|
||||
|
||||
public record DeviceXml(string Scheme, string Host)
|
||||
public record DeviceXml(string Scheme, string Host, Guid uuid)
|
||||
{
|
||||
public string ToXml() =>
|
||||
@$"<root xmlns=""urn:schemas-upnp-org:device-1-0"">
|
||||
@@ -15,8 +15,8 @@ public record DeviceXml(string Scheme, string Host)
|
||||
<manufacturer>Silicondust</manufacturer>
|
||||
<modelName>HDTC-2US</modelName>
|
||||
<modelNumber>HDTC-2US</modelNumber>
|
||||
<serialNumber/>
|
||||
<UDN>uuid:2020-03-S3LA-BG3LIA:2</UDN>
|
||||
<serialNumber>{uuid}</serialNumber>
|
||||
<UDN>uuid:{uuid}</UDN>
|
||||
</device>
|
||||
</root>";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ErsatzTV.Core.Hdhr;
|
||||
|
||||
@@ -8,21 +8,23 @@ public class Discover
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
private readonly Guid _UUID;
|
||||
|
||||
public Discover(string scheme, string host, int tunerCount)
|
||||
public Discover(string scheme, string host, int tunerCount, Guid uuid)
|
||||
{
|
||||
_scheme = scheme;
|
||||
_host = host;
|
||||
TunerCount = tunerCount;
|
||||
_UUID = uuid;
|
||||
}
|
||||
|
||||
public string DeviceAuth => "";
|
||||
public string DeviceID => "ErsatzTV";
|
||||
public string DeviceID => _UUID.ToString();
|
||||
public string FirmwareName => "hdhomeruntc_atsc";
|
||||
public string FirmwareVersion => "20190621";
|
||||
public string FriendlyName => "ErsatzTV";
|
||||
public string LineupURL => $"{_scheme}://{_host}/lineup.json";
|
||||
public string Manufacturer => "ErsatzTV - Silicondust";
|
||||
public string Manufacturer => "ErsatzTV";
|
||||
public string ManufacturerURL => "https://github.com/ErsatzTV/ErsatzTV";
|
||||
public string ModelNumber => "HDTC-2US";
|
||||
public int TunerCount { get; }
|
||||
|
||||
83
ErsatzTV.Core/Images/ChannelLogoGenerator.cs
Normal file
83
ErsatzTV.Core/Images/ChannelLogoGenerator.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Core.Images;
|
||||
|
||||
public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
{
|
||||
public const string GetRoute = "/iptv/logos/gen";
|
||||
public const string GetRouteQueryParamName = "text";
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public ChannelLogoGenerator(
|
||||
ILogger<ChannelLogoGenerator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public static Option<string> GenerateChannelLogoUrl(Channel channel) =>
|
||||
$"http://localhost:{Settings.ListenPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
|
||||
|
||||
public Either<BaseError, byte[]> GenerateChannelLogo(
|
||||
string text,
|
||||
int logoHeight,
|
||||
int logoWidth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var surface = SKSurface.Create(new SKImageInfo(logoWidth, logoHeight));
|
||||
SKCanvas canvas = surface.Canvas;
|
||||
canvas.Clear(SKColors.Black);
|
||||
|
||||
//etv logo
|
||||
string overlayImagePath = Path.Combine("wwwroot", "images", "ersatztv-500.png");
|
||||
using SKBitmap overlayImage = SKBitmap.Decode(overlayImagePath);
|
||||
canvas.DrawBitmap(overlayImage, new SKRect(155, 60, 205, 110));
|
||||
|
||||
//Custom Font
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
|
||||
using SKTypeface fontTypeface = SKTypeface.FromFile(fontPath);
|
||||
int fontSize = 30;
|
||||
SKPaint paint = new SKPaint
|
||||
{
|
||||
Typeface = fontTypeface,
|
||||
TextSize = fontSize,
|
||||
IsAntialias = true,
|
||||
Color = SKColors.White,
|
||||
Style = SKPaintStyle.Fill,
|
||||
TextAlign = SKTextAlign.Center
|
||||
};
|
||||
|
||||
SKRect textBounds = new SKRect();
|
||||
paint.MeasureText(text, ref textBounds);
|
||||
|
||||
// Ajuster la taille de la police si nécessaire
|
||||
while (textBounds.Width > logoWidth - 10 && fontSize > 16)
|
||||
{
|
||||
fontSize -= 2;
|
||||
paint.TextSize = fontSize;
|
||||
paint.MeasureText(text, ref textBounds);
|
||||
}
|
||||
|
||||
// Dessiner le texte
|
||||
float x = logoWidth / 2f;
|
||||
float y = logoHeight / 2f - textBounds.MidY;
|
||||
canvas.DrawText(text, x, y, paint);
|
||||
|
||||
using SKImage image = surface.Snapshot();
|
||||
using MemoryStream ms = new MemoryStream();
|
||||
image.Encode(SKEncodedImageFormat.Png, 100).SaveTo(ms);
|
||||
ms.Seek(0, SeekOrigin.Begin);
|
||||
return ms.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Can't generate Channel Logo ([{ErrorType}] {ErrorMessage})", ex.GetType(), ex.Message);
|
||||
return BaseError.New("Can't generate Channel Logo " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,32 +8,26 @@ public interface IEmbyApiClient
|
||||
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
|
||||
|
||||
IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
IAsyncEnumerable<Tuple<EmbyMovie, int>> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
|
||||
IAsyncEnumerable<EmbyShow> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
IAsyncEnumerable<Tuple<EmbyShow, int>> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
|
||||
IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
|
||||
IAsyncEnumerable<Tuple<EmbySeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId);
|
||||
|
||||
IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
|
||||
IAsyncEnumerable<Tuple<EmbyEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<EmbyCollection> GetCollectionLibraryItems(string address, string apiKey);
|
||||
IAsyncEnumerable<Tuple<EmbyCollection, int>> GetCollectionLibraryItems(string address, string apiKey);
|
||||
|
||||
IAsyncEnumerable<MediaItem> GetCollectionItems(string address, string apiKey, string collectionId);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
string parentId,
|
||||
string includeItemTypes);
|
||||
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(string address, string apiKey, string collectionId);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
|
||||
string address,
|
||||
|
||||
12
ErsatzTV.Core/Interfaces/Images/IChannelLogoGenerator.cs
Normal file
12
ErsatzTV.Core/Interfaces/Images/IChannelLogoGenerator.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Images;
|
||||
|
||||
public interface IChannelLogoGenerator
|
||||
{
|
||||
Either<BaseError, byte[]> GenerateChannelLogo(
|
||||
string text,
|
||||
int logoHeight,
|
||||
int logoWidth,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -9,38 +9,30 @@ public interface IJellyfinApiClient
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
|
||||
Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey);
|
||||
|
||||
IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
|
||||
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<JellyfinCollection> GetCollectionLibraryItems(string address, string apiKey, int mediaSourceId);
|
||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(string address, string apiKey, int mediaSourceId);
|
||||
|
||||
IAsyncEnumerable<MediaItem> GetCollectionItems(
|
||||
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string collectionId);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string parentId,
|
||||
string includeItemTypes,
|
||||
bool excludeFolders);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
|
||||
string address,
|
||||
string apiKey,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
public interface IPlexOtherVideoLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex;
|
||||
@@ -13,33 +13,28 @@ public interface IPlexServerApiClient
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexMovie> GetMovieLibraryContents(
|
||||
IAsyncEnumerable<Tuple<PlexMovie, int>> GetMovieLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexShow> GetShowLibraryContents(
|
||||
IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, int>> CountShowSeasons(
|
||||
PlexShow show,
|
||||
IAsyncEnumerable<Tuple<PlexShow, int>> GetShowLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexSeason> GetShowSeasons(
|
||||
IAsyncEnumerable<Tuple<PlexSeason, int>> GetShowSeasons(
|
||||
PlexLibrary library,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, int>> CountSeasonEpisodes(
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexEpisode> GetSeasonEpisodes(
|
||||
IAsyncEnumerable<Tuple<PlexEpisode, int>> GetSeasonEpisodes(
|
||||
PlexLibrary library,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
@@ -57,23 +52,25 @@ public interface IPlexServerApiClient
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, Tuple<OtherVideoMetadata, MediaVersion>>> GetOtherVideoMetadataAndStatistics(
|
||||
int plexMediaSourceId,
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library);
|
||||
|
||||
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
|
||||
int plexMediaSourceId,
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexCollection> GetAllCollections(
|
||||
IAsyncEnumerable<Tuple<PlexCollection, int>> GetAllCollections(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<MediaItem> GetCollectionItems(
|
||||
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
string key,
|
||||
|
||||
@@ -6,11 +6,17 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
public interface IMediaCollectionRepository
|
||||
{
|
||||
Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(int playlistId);
|
||||
Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(string groupName, string name);
|
||||
Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(Playlist playlist);
|
||||
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
|
||||
Task<List<MediaItem>> GetItems(int id);
|
||||
Task<List<MediaItem>> GetCollectionItemsByName(string name);
|
||||
Task<List<MediaItem>> GetMultiCollectionItems(int id);
|
||||
Task<List<MediaItem>> GetMultiCollectionItemsByName(string name);
|
||||
Task<List<MediaItem>> GetSmartCollectionItems(int id);
|
||||
Task<List<MediaItem>> GetSmartCollectionItemsByName(string name);
|
||||
Task<List<MediaItem>> GetSmartCollectionItems(string query);
|
||||
Task<List<MediaItem>> GetShowItemsByShowGuids(List<string> guids);
|
||||
Task<List<MediaItem>> GetPlaylistItems(int id);
|
||||
Task<List<Movie>> GetMovie(int id);
|
||||
Task<List<Episode>> GetEpisode(int id);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMediaItemRepository
|
||||
{
|
||||
Task<List<CultureInfo>> GetAllKnownCultures();
|
||||
Task<List<CultureInfo>> GetAllLanguageCodeCultures();
|
||||
Task<List<LanguageCodeAndName>> GetAllLanguageCodesAndNames();
|
||||
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
|
||||
Task<Unit> FlagNormal(MediaItem mediaItem);
|
||||
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMediaServerOtherVideoRepository<in TLibrary, TOtherVideo, TEtag> where TLibrary : Library
|
||||
where TOtherVideo : OtherVideo
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
Task<List<TEtag>> GetExistingOtherVideos(TLibrary library);
|
||||
Task<Option<int>> FlagNormal(TLibrary library, TOtherVideo otherVideo);
|
||||
Task<Option<int>> FlagUnavailable(TLibrary library, TOtherVideo otherVideo);
|
||||
Task<Option<int>> FlagRemoteOnly(TLibrary library, TOtherVideo otherVideo);
|
||||
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);
|
||||
Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> GetOrAdd(TLibrary library, TOtherVideo item, bool deepScan);
|
||||
Task<Unit> SetEtag(TOtherVideo otherVideo, string etag);
|
||||
}
|
||||
@@ -20,7 +20,8 @@ public interface IMediaSourceRepository
|
||||
Task<List<int>> UpdateLibraries(
|
||||
int plexMediaSourceId,
|
||||
List<PlexLibrary> toAdd,
|
||||
List<PlexLibrary> toDelete);
|
||||
List<PlexLibrary> toDelete,
|
||||
List<PlexLibrary> toUpdate);
|
||||
|
||||
Task<List<int>> UpdateLibraries(
|
||||
int jellyfinMediaSourceId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -28,11 +28,14 @@ public interface IMetadataRepository
|
||||
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(OtherVideoMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsExternal(ShowMetadata metadata);
|
||||
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
|
||||
Task<Unit> MarkAsExternal(MovieMetadata metadata);
|
||||
Task<Unit> MarkAsExternal(OtherVideoMetadata metadata);
|
||||
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
|
||||
Task<Unit> SetContentRating(OtherVideoMetadata metadata, string contentRating);
|
||||
|
||||
[SuppressMessage("Naming", "CA1720:Identifier contains type name")]
|
||||
Task<bool> RemoveGuid(MetadataGuid guid);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user