Compare commits

...

76 Commits

Author SHA1 Message Date
Jason Dove
4b9fc5004f update changelog for release v0.8.8-beta [no ci] 2024-09-19 11:15:41 -05:00
embolon
f40eaef898 [scheduling] Add a new mode RandomRotation that randomly picks an item from a randomly choosen group (show/artist) for block schedule (#1885)
* init

* minor naming change

* address to comments round 1

* update dependencies

* formatting

* make sure it rotates

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-09-10 13:36:20 -05:00
embolon
91e85cc9c1 [Filler] Add random count for filler preset (#1886)
* init

* minor update

* clean up

* minor cleanup

* update changelog

* update changelog again

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-09-03 10:25:20 -05:00
Jason Dove
2c44efb971 update dependencies (#1891) 2024-09-03 10:09:37 -05:00
Jason Dove
c2b7be66af restart hls session in some cases (#1880) 2024-08-23 12:59:48 -05:00
Jason Dove
8b911332a6 fix watermark opacity for transparent watermarks (#1877) 2024-08-22 11:35:44 -05:00
Jason Dove
4130f7316c fix block playout history loading (#1876) 2024-08-22 09:05:21 -05:00
Jason Dove
3f6eb5a121 fix some collection related bugs with images (#1874) 2024-08-21 10:36:42 -05:00
Jason Dove
1209c54eb9 prevent saving overlapping blocks (#1872) 2024-08-15 10:37:57 -05:00
Jason Dove
94db4bf679 fix local subtitle scans for non-lowercase extensions (#1865)
* fix local subtitle scans for non-lowercase extensions

* remove some unneeded changes
2024-08-07 20:47:27 -05:00
Jason Dove
2977590a14 add deco setting to use watermarks during filler (#1861) 2024-08-05 13:40:24 -05:00
Jason Dove
b4c168e85e use trakt user slug for proper url generation (#1859) 2024-08-05 09:41:24 -05:00
Jason Dove
55b7a35689 fix missing movie metadata (#1854) 2024-08-03 08:05:50 -05:00
Jason Dove
a24592a8c4 add database cleaner (#1853)
* fix broken tests

* add database cleaner
2024-08-02 10:46:52 -05:00
Jason Dove
9b60ff0863 optionally shuffle marathon groups (#1850) 2024-07-31 17:33:08 -05:00
Jason Dove
efdf0bb6d4 group music videos by album (#1849) 2024-07-31 16:13:13 -05:00
Sylvain
39ca27cb3d Overlay Generated Channel Logo when active but no artwork is found (#1848) 2024-07-31 10:04:42 -05:00
Jason Dove
9e2f7b7815 fix deco selection for watermark and filler (#1847) 2024-07-30 21:57:21 -05:00
Jason Dove
101d46e283 dont remove block items that are filler (#1846) 2024-07-30 20:38:13 -05:00
Jason Dove
521e4eac41 add yaml marathon search content source (#1845)
* use search queries to populate marathons

* group marathon by artist

* add marathon group by album
2024-07-30 20:30:13 -05:00
Jason Dove
894fc284b2 fix deco template name display (#1844)
* fix deco template name display

* try to fix mac build
2024-07-30 19:45:25 -05:00
Jason Dove
a8cf22e43e group marathon by season (#1843) 2024-07-30 19:24:56 -05:00
Jason Dove
4c9c047530 add basic marathon content (#1842) 2024-07-30 18:21:24 -05:00
Jason Dove
912f79097d add collection, smart collection, multi collection, playlist content sources to yaml playouts (#1841)
* add collection content to yaml playout

* add smart_collection content

* add multi_collection content

* add playlist content
2024-07-30 10:46:08 -05:00
Jason Dove
8aa55fdfce replace new_epg_group instruction with epg_group; copy sequence custom title to sequence items 2024-07-30 08:36:14 -05:00
Jason Dove
8dc1cab222 fix media card selection (#1840) 2024-07-30 06:21:38 -05:00
Jason Dove
961fe8bbf2 improve shuffling behavior; add custom_title (#1838)
* improve yaml shuffling behavior

* add custom_title to playout instructions
2024-07-29 19:53:06 -05:00
Jason Dove
75f991d670 yaml history fix (#1836) 2024-07-29 16:21:51 -05:00
Jason Dove
e3c981004b show all items in epg by default (#1835) 2024-07-29 15:48:20 -05:00
Jason Dove
befaa037e2 default duration to make a new epg group per item; default duration to NOT use offline tail (#1834) 2024-07-29 15:26:13 -05:00
Jason Dove
5e0fb31069 add reset playout and scan library api endpoints (#1833)
* add api to reset playout

* add library scan api

* update changelog
2024-07-29 13:50:33 -05:00
Jason Dove
7d83e66ba6 add yaml playout history; allow yaml playouts to be extended (#1832)
* add multi_part; refactor skipping items

* save and apply history for yaml playouts

* do not remove history on yaml playout reset
2024-07-29 13:09:14 -05:00
Jason Dove
391528cd94 add pad_until instruction (#1831)
* revert dotnet workaround

* add pad_until instruction
2024-07-29 06:36:14 -05:00
Jason Dove
b737775f9a add yaml skip to item instruction (#1830)
* work around MSB3374 error

* add skip to item instruction
2024-07-28 22:12:54 -05:00
Jason Dove
728c5130b5 try without quotes 2024-07-28 17:34:25 -05:00
Jason Dove
e4253276e0 let's try completely separate folders 2024-07-28 16:49:07 -05:00
Jason Dove
1fc55bc693 try to fix build again 2024-07-28 16:44:38 -05:00
Jason Dove
4ad22e402f use global.json dotnet version in workflows (#1829)
* use global.json dotnet version in workflows

* list output files

* work around weird folder emptying behavior on windows
2024-07-28 16:39:19 -05:00
Jason Dove
ec99d5976d add shuffle sequence instruction (#1827) 2024-07-28 13:51:26 -05:00
Jason Dove
59f11f1a1a add yaml playout sequences (#1826) 2024-07-28 13:22:01 -05:00
Jason Dove
694f25f8b3 upgrade to mudblazor 7 (#1825) 2024-07-28 11:31:04 -05:00
Jason Dove
5947555e86 improve trakt list url validation (#1824)
* improve trakt url validation and logging

* update changelog
2024-07-28 09:24:54 -05:00
Jason Dove
fb63116b36 more subtitles fixes (#1823) 2024-07-27 22:20:44 -05:00
Jason Dove
56a58d7a84 fix missing audio and subtitle language codes (#1822) 2024-07-27 20:44:49 -05:00
Jason Dove
6f66909957 add "all" instruction (#1821)
* add "all" instruction

* use string for value we don't care about
2024-07-27 14:26:33 -05:00
Sylvain
01090f62e6 Fixing URL Encoding for logo generation (#1818) 2024-07-27 10:37:37 -05:00
Jason Dove
e4e4f68eb4 refactor yaml playout builder (#1820)
* update changelog

* refactor some handlers

* refactor skip items instruction

* more refactoring
2024-07-27 10:33:21 -05:00
Sylvain
8488fe5d3d Used a UUID in HDHomeRun config to allow multiple instances on a same network (#1810)
* Used a UUID in HDHomeRun config to allow multiple instances on a same network

* tweak some async calls

* try to fix line endings

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-07-27 08:51:45 -05:00
Jason Dove
f06ef5262a add new_epg_group instruction; add filler_kind propery (#1819) 2024-07-27 08:38:00 -05:00
Jason Dove
ae6bcc4933 add yaml playout skip items instruction (#1816) 2024-07-26 20:37:02 -05:00
Jason Dove
b83fe53ef1 add wait until instruction (#1815) 2024-07-26 19:52:43 -05:00
Jason Dove
d50f2ace07 fix regression selecting subtitle streams for certain languages (#1814) 2024-07-26 17:44:17 -05:00
Sylvain
23684f607a Generating Channel Logo when no logo is provided (#1807)
* Generating Channel Logo when none is provided

* Moved TTF in the cached Resources folder

* Using WebUtility.UrlEncode instead of Raw String Replace

* Fixed mistyping

* Moved Channel Logo Generator to etv.core

* Return 301 to static logo if there is any error during Logo generation

* minor fixes

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-07-26 15:13:00 -05:00
Jason Dove
fa20c5e01e template playout => yaml playout (#1813) 2024-07-26 14:33:55 -05:00
Jason Dove
53bd745678 add playout template show content (#1812) 2024-07-26 13:10:27 -05:00
Jason Dove
f3e5a4e7d8 add playout template duration scheduler (#1811)
* fix loop with missing content

* implement template duration scheduler
2024-07-26 12:03:46 -05:00
Jason Dove
0b29bb32b1 playout template pad to next fixes (#1809)
* prevent loop

* add discard attempts and fallback to pad_to_next
2024-07-26 10:23:38 -05:00
Jason Dove
d9a7615cf6 add experimental playout template system (#1808)
* add template playout kind

* add template scheduler count

* implement pad to next

* only allow resetting template playouts

* update changelog
2024-07-26 08:34:18 -05:00
Jason Dove
50f2cb7a33 fix adding pad filler to short content (#1806) 2024-07-25 19:14:16 -05:00
Jason Dove
b1b2c2a1e0 add deco default filler trim to fit setting (#1800)
* add deco default filler trim to fit setting

* implement trim to fit

* update changelog
2024-07-22 14:14:02 -05:00
Jason Dove
d842cd57f6 fix building block playouts without default filler (#1799) 2024-07-22 09:05:31 -05:00
Jason Dove
4f393d7b06 fix two letter language code stream selection (#1798) 2024-07-22 06:54:08 -05:00
Jason Dove
46f7289db8 add deco default filler (#1797)
* first pass at default filler for block scheduling

* configure default filler in ui

* update changelog
2024-07-19 13:29:42 -05:00
Jason Dove
80ccbbf299 fix duration playout loop (#1796) 2024-07-18 12:18:49 -05:00
Jason Dove
3765894cb7 remove invalid values from filler preset editor (#1793) 2024-07-17 16:51:39 -05:00
Jason Dove
a8b658a5ea add "on demand" channel progress mode (#1790)
* update dependencies

* add channel progress mode

* implement on demand channel progress

* update changelog
2024-07-16 12:21:52 -05:00
Sylvain
0e3c32bd83 Adding more HEAD handling (https://github.com/ErsatzTV/ErsatzTV/pull/1786) (#1787) 2024-07-13 06:28:28 -05:00
Jason Dove
9dd4a85bf9 fix adding items to empty playlists (#1784) 2024-07-11 12:17:41 -05:00
Sylvain
a0a047ba18 Added API Artwork Router (#1776)
* Added API Artwork Router

* Simplifying code
2024-07-08 15:15:14 -05:00
Sylvain
687a4f4f10 Alow HEAD requests on /iptv/channels.m3u (#1779) 2024-07-08 13:10:36 -05:00
Sylvain
b91ab5d898 Fixing Artwork OtherVideo rel (#1774)
* Fixing Artwork OtherVideo rel

* show other video artwork in ui

* don't run code quality on PRs

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-07-05 09:16:09 -05:00
Jason Dove
256042947d use macos-12 runners for github actions (#1773) 2024-07-05 06:39:09 -05:00
Sylvain
85029cbbcd Escaping & on xmltv file (#1772) 2024-07-05 05:56:14 -05:00
Jason Dove
b5d679212d cache bust xmltv images (#1771) 2024-07-03 12:14:40 -05:00
Sylvain
36e86587ef Allow Other Videos Library Type on Plex to be sync (#1766)
* Allow Other Videos Library Type on Plex to be sync

* Migrating database: Creating PlexOtherVideo table

* Using Plex Media path to create tags for OtherVideos

* missed these in the merge

* Getting PlexLibrary for Tag set on OtherVideo

* fix migrations

* set tag metadata on plex other videos

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2024-07-02 15:41:09 -05:00
Jason Dove
f41fa669be update media server scanning and paging (#1770)
* update media server scanning and paging

* remove unused types
2024-07-02 13:12:09 -05:00
315 changed files with 111730 additions and 1201 deletions

8
.gitattributes vendored Normal file
View 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

View File

@@ -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

View File

@@ -1,7 +1,6 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
push:
branches:
- main

View File

@@ -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

View File

@@ -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

View File

@@ -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));

View 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>>;

View 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
};
}
}

View File

@@ -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);
}

View File

@@ -12,6 +12,7 @@ public record CreateChannel(
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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();

View File

@@ -13,6 +13,7 @@ public record UpdateChannel(
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,

View File

@@ -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");
}

View File

@@ -16,6 +16,7 @@ internal static class Mapper
GetLogo(channel),
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.ProgressMode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,

View File

@@ -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 += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.HDHR;
public record GetHDHRUUID : IRequest<Guid>;

View 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;
});
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Libraries;
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;

View File

@@ -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;
}
}

View File

@@ -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)
{

View File

@@ -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; }

View File

@@ -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));

View File

@@ -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)
{

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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");
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record TimeShiftOnDemandPlayout(string ChannelNumber, DateTimeOffset Now, bool Force)
: IRequest<Option<BaseError>>, IBackgroundServiceRequest;

View File

@@ -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);
}
}
}

View File

@@ -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(

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Playouts;
public record UpdateOnDemandCheckpoint(string ChannelNumber, DateTimeOffset Checkpoint)
: IRequest;

View File

@@ -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);
}
}
}

View File

@@ -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) =>

View File

@@ -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."));
}

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record UpdateYamlPlayout(int PlayoutId, string TemplateFile)
: IRequest<Either<BaseError, PlayoutNameViewModel>>;

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutById(int PlayoutId) : IRequest<Option<PlayoutNameViewModel>>;

View File

@@ -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));
}
}

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutIdByChannelNumber(string ChannelNumber) : IRequest<Option<int>>;

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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:

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record EraseBlockPlayoutHistory(int PlayoutId) : IRequest;

View File

@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Scheduling;
public record EraseBlockPlayoutItems(int PlayoutId) : IRequest;

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record ErasePlayoutHistory(int PlayoutId) : IRequest;

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Scheduling;
public record ErasePlayoutItems(int PlayoutId) : IRequest;

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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
}
}
}

View File

@@ -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);

View File

@@ -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));

View File

@@ -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>

View 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");
}
}
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,

View File

@@ -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>>()),

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelProgressMode
{
Always = 0,
OnDemand = 1
}

View File

@@ -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");

View File

@@ -5,5 +5,6 @@ public enum FillerMode
None = 0,
Duration = 1,
Count = 2,
Pad = 3
Pad = 3,
RandomCount = 4
}

View File

@@ -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; }

View File

@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class PlexOtherVideo : OtherVideo
{
public string Key { get; set; }
public string Etag { get; set; }
}

View File

@@ -7,5 +7,6 @@ public enum PlaybackOrder
Shuffle = 3,
ShuffleInOrder = 4,
MultiEpisodeShuffle = 5,
SeasonEpisode = 6
SeasonEpisode = 6,
RandomRotation = 7
}

View File

@@ -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; }
}

View File

@@ -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();

View File

@@ -5,6 +5,7 @@ public enum ProgramSchedulePlayoutType
None = 0,
Flood = 1,
Block = 2,
Yaml = 3,
ExternalJson = 20
}

View File

@@ -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; }

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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>

View 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);
}
}
}

View File

@@ -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,

View File

@@ -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))

View File

@@ -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>";
}

View File

@@ -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; }

View 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);
}
}
}

View File

@@ -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,

View 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);
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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