Compare commits

..
Author SHA1 Message Date
Jason Dove 1c1c1e7812 update changelog for release 50 [no docker] 2021-07-13 20:29:00 -05:00
Jason DoveandGitHub 5f802c7484 include linux-arm release artifacts [no docker] (#306) 2021-07-13 20:21:37 -05:00
Jason DoveandGitHub 7a06ac71e2 fix movie fallback metadata titles (#305) 2021-07-13 20:15:52 -05:00
Jason DoveandGitHub 3b9b8796b9 fix movie fallback metadata (#302) 2021-07-12 07:44:48 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a72d91507e Bump FluentValidation.AspNetCore from 10.2.3 to 10.3.0 (#301)
Bumps [FluentValidation.AspNetCore](https://github.com/JeremySkinner/fluentvalidation) from 10.2.3 to 10.3.0.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.2.3...10.3.0)

---
updated-dependencies:
- dependency-name: FluentValidation.AspNetCore
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-12 06:05:54 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
45bfbfc179 Bump FluentValidation from 10.2.3 to 10.3.0 (#300)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.2.3 to 10.3.0.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.2.3...10.3.0)

---
updated-dependencies:
- dependency-name: FluentValidation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-12 05:51:29 -05:00
Jason Dove 7d24701a82 update changelog for release 49 [no docker] 2021-07-11 18:00:50 -05:00
Jason DoveandGitHub 286580d5aa more sorting fixes (#299) 2021-07-11 14:40:31 -05:00
Jason DoveandGitHub d9457c01e5 add special case to multiple playout mode (#298) 2021-07-11 14:25:42 -05:00
Jason DoveandGitHub 22cf759a29 sorting fixes (#297)
* sorting fixes

* use natural sort for add to schedule dialog
2021-07-11 13:20:42 -05:00
Jason DoveandGitHub 0733a3d8d7 fix loading playout anchors (#296) 2021-07-11 10:47:25 -05:00
Jason DoveandGitHub 5f28707cce include audio language metadata in all streaming modes (#295)
* include audio language metadata in all streaming modes

* cleanup
2021-07-10 06:16:46 -05:00
Jason DoveandGitHub 45f1c6b22a properly flood with fixed start time (#294) 2021-07-09 14:16:24 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3bed81aee9 Bump MudBlazor from 5.0.14 to 5.0.15 (#292)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.14 to 5.0.15.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.14...v5.0.15)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-09 13:49:52 -05:00
Jason DoveandGitHub f2eda3033c update dependencies (#289) 2021-06-24 06:35:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8ce989c3c9 Bump Microsoft.VisualStudio.Threading.Analyzers from 16.9.60 to 16.10.56 (#282)
Bumps [Microsoft.VisualStudio.Threading.Analyzers](https://github.com/microsoft/vs-threading) from 16.9.60 to 16.10.56.
- [Release notes](https://github.com/microsoft/vs-threading/releases)
- [Commits](https://github.com/microsoft/vs-threading/compare/v16.9.60...v16.10.56)

---
updated-dependencies:
- dependency-name: Microsoft.VisualStudio.Threading.Analyzers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 13:06:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b5ba0dff27 Bump Blazored.LocalStorage from 4.1.1 to 4.1.2 (#286)
Bumps [Blazored.LocalStorage](https://github.com/Blazored/LocalStorage) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/Blazored/LocalStorage/releases)
- [Commits](https://github.com/Blazored/LocalStorage/compare/v4.1.1...v4.1.2)

---
updated-dependencies:
- dependency-name: Blazored.LocalStorage
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 13:06:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de3e2ea754 Bump NUnit3TestAdapter from 3.17.0 to 4.0.0 (#283)
Bumps [NUnit3TestAdapter](https://github.com/nunit/nunit3-vs-adapter) from 3.17.0 to 4.0.0.
- [Release notes](https://github.com/nunit/nunit3-vs-adapter/releases)
- [Commits](https://github.com/nunit/nunit3-vs-adapter/compare/V3.17...V4.0.0)

---
updated-dependencies:
- dependency-name: NUnit3TestAdapter
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:44:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2ac840b4bd Bump Markdig from 0.24.0 to 0.25.0 (#284)
Bumps [Markdig](https://github.com/xoofx/markdig) from 0.24.0 to 0.25.0.
- [Release notes](https://github.com/xoofx/markdig/releases)
- [Changelog](https://github.com/xoofx/markdig/blob/master/changelog.md)
- [Commits](https://github.com/xoofx/markdig/compare/0.24.0...0.25.0)

---
updated-dependencies:
- dependency-name: Markdig
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:43:53 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c8ccb5b0a0 Bump Microsoft.NET.Test.Sdk from 16.9.4 to 16.10.0 (#285)
Bumps [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) from 16.9.4 to 16.10.0.
- [Release notes](https://github.com/microsoft/vstest/releases)
- [Commits](https://github.com/microsoft/vstest/compare/v16.9.4...v16.10.0)

---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-23 05:43:42 -05:00
Jason Dove 23c4fcf42c update changelog for release 48 [no docker] 2021-06-22 20:05:43 -05:00
Jason DoveandGitHub e2f3e86fd6 fix adding jellyfin emby seasons episodes (#281)
* fix adding new seasons and episodes with emby and jellyfin

* update changelog

* update dependencies
2021-06-22 18:55:44 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd9f4a8f4e Bump MudBlazor from 5.0.10 to 5.0.14 (#278)
Bumps [MudBlazor](https://github.com/Garderoben/MudBlazor) from 5.0.10 to 5.0.14.
- [Release notes](https://github.com/Garderoben/MudBlazor/releases)
- [Changelog](https://github.com/Garderoben/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/Garderoben/MudBlazor/compare/v5.0.10...v5.0.14)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:28:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d5a0951a9b Bump FluentValidation from 10.1.0 to 10.2.3 (#275)
Bumps [FluentValidation](https://github.com/JeremySkinner/fluentvalidation) from 10.1.0 to 10.2.3.
- [Release notes](https://github.com/JeremySkinner/fluentvalidation/releases)
- [Changelog](https://github.com/FluentValidation/FluentValidation/blob/main/Changelog.txt)
- [Commits](https://github.com/JeremySkinner/fluentvalidation/compare/10.1.0...10.2.3)

---
updated-dependencies:
- dependency-name: FluentValidation
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-22 06:21:28 -05:00
Jason Dove 56d9724efd remove BOM from dependabot.yml [no docker] 2021-06-22 06:08:04 -05:00
Jason Dove f91b5ab3b5 assign dependabot prs [no docker] 2021-06-22 06:06:20 -05:00
Jason Dove 4b8e81ff06 add dependabot config [no docker] 2021-06-22 06:00:54 -05:00
Jason DoveandGitHub 1a7e6dda54 support 10-bit content with nvidia acceleration (#273)
* use ffprobe for plex statistics

* emby and jellyfin respect library refresh interval

* support 10-bit content with nvidia acceleration
2021-06-19 21:25:44 -05:00
Jason Dove 9fc6cdd0b7 update changelog for release 47 [no docker] 2021-06-15 16:25:01 -05:00
Jason DoveandGitHub cebab33d79 table improvements (#272)
* log viewer improvements

* playout detail table improvements

* schedule items table improvements

* remove schedule items pager
2021-06-15 16:10:24 -05:00
Jason DoveandGitHub b580125e86 fix searching when queries include non-ascii characters (#271) 2021-06-15 10:33:02 -05:00
Jason DoveandGitHub b38ba14c40 fix languages that have multiple codes (#270) 2021-06-15 06:12:40 -05:00
Jason DoveandGitHub c10bc6b184 fix blazor error font color (#269)
* fix blazor error font color

* changelog
2021-06-14 20:32:20 -05:00
Jason DoveandGitHub a75737a032 clear playout detail on delete (#266) 2021-06-14 18:44:10 -05:00
Jason DoveandGitHub 57aa14b764 fix adding channel with no watermark (#265) 2021-06-14 18:31:50 -05:00
94 changed files with 19955 additions and 513 deletions
+8
View File
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
+3
View File
@@ -11,6 +11,9 @@ jobs:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: windows-latest
kind: windows
target: win-x64
+62 -2
View File
@@ -5,6 +5,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.50-alpha] - 2021-07-13
### Added
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
### Fixed
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
## [0.0.49-prealpha] - 2021-07-11
### Added
- Include audio language metadata in all streaming modes
- Add special zero-count case to `Multiple` playout mode
- This configuration will automatically maintain the multiple count so that it is equal to the number of items in the collection
- This configuration should be used if you want to play every item in a collection exactly once before advancing
### Changed
- Use case-insensitive sorting for collections page and `Add to Collection` dialog
- Use case-insensitive sorting for all collection lists in schedule items editor
- Use natural sorting for schedules page and `Add to Schedule` dialog
### Fixed
- Fix flooding schedule items that have a fixed start time
## [0.0.48-prealpha] - 2021-06-22
### Added
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
### Fixed
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
- Emby and Jellyfin scanners now respect library refresh interval setting
- Fix adding new seasons to existing Emby and Jellyfin shows
- Fix adding new episodes to existing Emby and Jellyfin seasons
## [0.0.47-prealpha] - 2021-06-15
### Added
- Add warning during playout rebuild when schedule has been emptied
- Save Logs, Playout Detail, Schedule Detail table page sizes
### Changed
- Show all log entries in log viewer, not just most recent 100 entries
- Use server-side paging and sorting for Logs table
- Use server-side paging for Playout Detail table
- Remove pager from Schedule Items editor (all schedule items will always be displayed)
### Fixed
- Fix ui crash adding a channel without a watermark
- Clear playout detail table when playout is deleted
- Fix blazor error font color
- Fix some audio stream languages missing from UI and search index
- Fix audio stream selection for languages with multiple codes
- Fix searching when queries contain non-ascii characters
## [0.0.46-prealpha] - 2021-06-14
### Added
- Add watermark opacity setting to allow blending with content
@@ -18,7 +74,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This allows easy watermark reuse across channels
### Fixed
- Fix crash adding or editing schedule items due to Artist with no name
- Fix ui crash adding or editing schedule items due to Artist with no name
- Fix many potential sources of inconsistent data in UI
## [0.0.45-prealpha] - 2021-06-12
@@ -449,7 +505,11 @@ 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/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...HEAD
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
@@ -28,8 +28,9 @@ namespace ErsatzTV.Application.Artists.Queries
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist);
return ProjectToViewModel(artist, languages);
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
return ProjectToViewModel(artist, languageCodes);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
}
@@ -38,7 +38,7 @@ namespace ErsatzTV.Application.Channels.Commands
return new CreateChannelResult(channel.Id);
}
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request),
@@ -66,10 +66,14 @@ namespace ErsatzTV.Application.Channels.Commands
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode,
WatermarkId = watermarkId
PreferredLanguageCode = preferredLanguageCode
};
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
return channel;
});
@@ -111,20 +115,20 @@ namespace ErsatzTV.Application.Channels.Commands
.MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
private static async Task<Validation<BaseError, int?>> WatermarkMustExist(
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.WatermarkId is null)
{
return createChannel.WatermarkId;
return Option<int>.None;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.WatermarkId)
.MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
}
}
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
embyLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
ConnectionParameters ConnectionParameters,
EmbyLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -13,7 +13,7 @@
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
{
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(
ISynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
jellyfinLibrary,
request.ForceScan,
libraryRefreshInterval,
ffprobePath
));
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
ConnectionParameters ConnectionParameters,
JellyfinLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Logs
{
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
}
@@ -1,7 +1,14 @@
using System.Collections.Generic;
using System;
using System.Linq.Expressions;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Logs.Queries
{
public record GetRecentLogEntries : IRequest<List<LogEntryViewModel>>;
public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
{
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
public Option<bool> SortDescending { get; set; }
}
}
@@ -2,20 +2,46 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Logs.Mapper;
namespace ErsatzTV.Application.Logs.Queries
{
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, List<LogEntryViewModel>>
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
{
private readonly ILogRepository _logRepository;
private readonly IDbContextFactory<LogContext> _dbContextFactory;
public GetRecentLogEntriesHandler(ILogRepository logRepository) => _logRepository = logRepository;
public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public Task<List<LogEntryViewModel>> Handle(GetRecentLogEntries request, CancellationToken cancellationToken) =>
_logRepository.GetRecentLogEntries().Map(list => list.Map(ProjectToViewModel).ToList());
public async Task<PagedLogEntriesViewModel> Handle(
GetRecentLogEntries request,
CancellationToken cancellationToken)
{
await using LogContext logContext = _dbContextFactory.CreateDbContext();
int count = await logContext.LogEntries.CountAsync(cancellationToken);
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
.OrderByDescending(le => le.Id);
foreach (bool descending in request.SortDescending)
{
ordered = descending
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
}
List<LogEntryViewModel> page = await ordered
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedLogEntriesViewModel(count, page);
}
}
}
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
List<MediaCollectionViewModel> page = await dbContext.Collections.FromSqlRaw(
@"SELECT * FROM Collection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
@@ -1,35 +1,53 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaItemRepository _mediaItemRepository;
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
public GetAllLanguageCodesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaItemRepository mediaItemRepository)
{
_dbContextFactory = dbContextFactory;
_mediaItemRepository = mediaItemRepository;
}
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
{
var result = new List<CultureInfo>();
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var result = new System.Collections.Generic.HashSet<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string code in allLanguageCodes)
List<string> mediaCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string mediaCode in mediaCodes)
{
Option<CultureInfo> maybeCulture = allCultures.Find(
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
foreach (string code in await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCode))
{
Option<CultureInfo> maybeCulture = allCultures.Find(
c => string.Equals(code, c.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
foreach (CultureInfo culture in maybeCulture)
{
result.Add(culture);
}
}
}
return result;
return result.ToList();
}
}
}
+4 -5
View File
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.Movies
{
internal static MovieViewModel ProjectToViewModel(
Movie movie,
List<string> languageCodes,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
@@ -28,7 +29,7 @@ namespace ErsatzTV.Application.Movies
metadata.Studios.Map(s => s.Name).ToList(),
(metadata.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(),
LanguagesForMovie(movie),
LanguagesForMovie(languageCodes),
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(),
@@ -40,13 +41,11 @@ namespace ErsatzTV.Application.Movies
};
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)
private static List<CultureInfo> LanguagesForMovie(List<string> languageCodes)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return movie.MediaVersions
.Map(mv => mv.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).Map(s => s.Language))
.Flatten()
return languageCodes
.Distinct()
.Map(
lang => allCultures.Filter(
@@ -1,9 +1,14 @@
using System.Threading;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Movies.Mapper;
namespace ErsatzTV.Application.Movies.Queries
@@ -11,10 +16,15 @@ namespace ErsatzTV.Application.Movies.Queries
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMovieRepository _movieRepository;
public GetMovieByIdHandler(IMovieRepository movieRepository, IMediaSourceRepository mediaSourceRepository)
public GetMovieByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMovieRepository movieRepository,
IMediaSourceRepository mediaSourceRepository)
{
_dbContextFactory = dbContextFactory;
_movieRepository = movieRepository;
_mediaSourceRepository = mediaSourceRepository;
}
@@ -23,6 +33,8 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
@@ -30,7 +42,20 @@ namespace ErsatzTV.Application.Movies.Queries
.Map(list => list.HeadOrNone());
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby));
Option<MediaVersion> maybeVersion = movie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
var languageCodes = new List<string>();
foreach (MediaVersion version in maybeVersion)
{
var mediaCodes = version.Streams
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
.Map(ms => ms.Language)
.ToList();
languageCodes.AddRange(await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes));
}
return movie.Map(m => ProjectToViewModel(m, languageCodes, maybeJellyfin, maybeEmby));
}
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Playouts
{
public record PagedPlayoutItemsViewModel(int TotalCount, List<PlayoutItemViewModel> Page);
}
@@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetPlayoutItemsById(int PlayoutId) : IRequest<List<PlayoutItemViewModel>>;
public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -11,27 +11,29 @@ using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts.Queries
{
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, List<PlayoutItemViewModel>>
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<PlayoutItemViewModel>> Handle(
public async Task<PagedPlayoutItemsViewModel> Handle(
GetPlayoutItemsById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.PlayoutItems
int totalCount = await dbContext.PlayoutItems
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken);
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
@@ -39,7 +41,6 @@ namespace ErsatzTV.Application.Playouts.Queries
.ThenInclude(mm => mm.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.Include(i => i.MediaItem)
@@ -49,8 +50,13 @@ namespace ErsatzTV.Application.Playouts.Queries
.ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(i => i.PlayoutId == request.PlayoutId)
.OrderBy(i => i.Start)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedPlayoutItemsViewModel(totalCount, page);
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -74,13 +75,15 @@ namespace ErsatzTV.Application.Plex.Commands
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library);
parameters.Library,
parameters.FFprobePath);
break;
}
@@ -100,13 +103,14 @@ namespace ErsatzTV.Application.Plex.Commands
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request),
await ValidateLibraryRefreshInterval())
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
.Apply(
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
connectionParameters,
plexLibrary,
request.ForceScan,
libraryRefreshInterval
libraryRefreshInterval,
ffprobePath
));
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
@@ -149,12 +153,20 @@ namespace ErsatzTV.Application.Plex.Commands
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri > 0)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexLibrary Library,
bool ForceScan,
int LibraryRefreshInterval);
int LibraryRefreshInterval,
string FFprobePath);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
@@ -30,9 +30,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
case PlayoutMode.One:
break;
case PlayoutMode.Multiple:
if (item.MultipleCount.GetValueOrDefault() <= 0)
if (item.MultipleCount.GetValueOrDefault() < 0)
{
return BaseError.New("[MultipleCount] must be greater than 0 for playout mode 'multiple'");
return BaseError.New("[MultipleCount] must be greater than or equal to 0 for playout mode 'multiple'");
}
break;
@@ -39,8 +39,9 @@ namespace ErsatzTV.Application.Television.Queries
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
return ProjectToViewModel(show, languages, maybeJellyfin, maybeEmby);
List<string> mediaCodes = await _searchRepository.GetLanguagesForShow(show);
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
}
@@ -13,14 +13,14 @@
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -93,5 +93,30 @@ namespace ErsatzTV.Core.Tests.Metadata
metadata.Count.Should().Be(2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
}
[Test]
[TestCase("Something (2021).mkv", "Something")]
[TestCase("Something Else (2021).mkv", "Something Else")]
public void GetFallbackMetadata_Should_Set_Proper_Movie_Title(string path, string expectedTitle)
{
MovieMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Movie
{
LibraryPath = new LibraryPath(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
});
metadata.Should().NotBeNull();
metadata.Title.Should().Be(expectedTitle);
}
}
}
@@ -458,6 +458,182 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items[5].MediaItemId.Should().Be(2);
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime()
{
var floodCollection = new Collection
{
Id = 1,
Name = "Flood Items",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
}
};
var fixedCollection = new Collection
{
Id = 2,
Name = "Fixed Items",
MediaItems = new List<MediaItem>
{
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(floodCollection.Id, floodCollection.MediaItems.ToList()),
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemFlood
{
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7)
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12)
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
};
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(24);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(6);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
result.Items[3].MediaItemId.Should().Be(2);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
result.Items[4].MediaItemId.Should().Be(1);
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[5].MediaItemId.Should().Be(3);
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
{
var floodCollection = new Collection
{
Id = 1,
Name = "Flood Items",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
}
};
var fixedCollection = new Collection
{
Id = 2,
Name = "Fixed Items",
MediaItems = new List<MediaItem>
{
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(floodCollection.Id, floodCollection.MediaItems.ToList()),
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemFlood
{
Index = 1,
Collection = floodCollection,
CollectionId = floodCollection.Id,
StartTime = TimeSpan.FromHours(7)
},
new ProgramScheduleItemOne
{
Index = 2,
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(12)
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(9).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
InFlood = true
}
};
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(32);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(5);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
result.Items[2].MediaItemId.Should().Be(1);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
result.Items[3].MediaItemId.Should().Be(3);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
result.Items[4].MediaItemId.Should().Be(2);
result.Anchor.InFlood.Should().BeTrue();
}
[Test]
public async Task FloodContent_Should_FloodAroundFixedContent_DurationWithoutOfflineTail()
{
@@ -729,6 +905,96 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Anchor.MultipleRemaining.Should().Be(1);
}
[Test]
public async Task Auto_Zero_MultipleCount()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Multiple Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(3, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Multiple Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(5, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 0
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 0
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
};
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(5);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(0));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[1].MediaItemId.Should().Be(2);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[2].MediaItemId.Should().Be(3);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[3].MediaItemId.Should().Be(4);
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[4].MediaItemId.Should().Be(5);
result.Anchor.NextScheduleItem.Should().Be(items[0]);
result.Anchor.MultipleRemaining.Should().BeNull();
}
[Test]
public async Task Alternating_Duration_Should_Maintain_Duration()
{
+3
View File
@@ -18,7 +18,10 @@
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class LanguageCode
{
public int Id { get; set; }
public string ThreeCode1 { get; set; }
public string ThreeCode2 { get; set; }
public string TwoCode { get; set; }
public string EnglishName { get; set; }
public string FrenchName { get; set; }
}
}
@@ -12,6 +12,8 @@
public string Title { get; set; }
public bool Default { get; set; }
public bool Forced { get; set; }
public string PixelFormat { get; set; }
public int BitsPerRawSample { get; set; }
public int MediaVersionId { get; set; }
public MediaVersion MediaVersion { get; set; }
}
+1
View File
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public DateTime NextStart { get; set; }
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public bool InFlood { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(incoming);
await _televisionRepository.AddSeason(show, incoming);
});
List<EmbyItemEtag> existingEpisodes =
@@ -364,7 +363,6 @@ namespace ErsatzTV.Core.Emby
try
{
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -373,7 +371,7 @@ namespace ErsatzTV.Core.Emby
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
+1 -1
View File
@@ -16,7 +16,7 @@
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.FFmpeg
private IDisplaySize _resolution;
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
private string _pixelFormat;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -63,6 +64,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
{
_pixelFormat = pixelFormat;
return this;
}
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
{
_watermark = watermark;
@@ -128,6 +135,8 @@ namespace ErsatzTV.Core.FFmpeg
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
@@ -150,6 +159,8 @@ namespace ErsatzTV.Core.FFmpeg
string format = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
_ => "format=nv12"
};
videoFilterQueue.Add(format);
+20 -4
View File
@@ -175,7 +175,7 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
{
@@ -183,7 +183,9 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add(qsvCodec);
}
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
_arguments.Add("-i");
_arguments.Add($"{input}");
@@ -228,14 +230,29 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithMetadata(Channel channel)
public FFmpegProcessBuilder WithMetadata(Channel channel, Option<MediaStream> maybeAudioStream)
{
if (channel.StreamingMode == StreamingMode.TransportStream)
{
_arguments.AddRange(new[] { "-map_metadata", "-1" });
}
foreach (MediaStream audioStream in maybeAudioStream)
{
if (!string.IsNullOrWhiteSpace(audioStream.Language))
{
_arguments.AddRange(new[] { "-metadata:s:a:0", $"language={audioStream.Language}" });
}
}
var arguments = new List<string>
{
"-metadata", "service_provider=\"ErsatzTV\"",
"-metadata", $"service_name=\"{channel.Name}\""
};
_arguments.AddRange(arguments);
return this;
}
@@ -321,7 +338,6 @@ namespace ErsatzTV.Core.FFmpeg
new[]
{
"-c:a", playbackSettings.AudioCodec,
"-map_metadata", "-1",
"-movflags", "+faststart",
"-muxdelay", "0",
"-muxpreload", "0"
+4 -4
View File
@@ -62,7 +62,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
@@ -105,7 +105,7 @@ namespace ErsatzTV.Core.FFmpeg
});
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts")
.WithDuration(start + version.Duration - now)
.WithPipe()
@@ -130,7 +130,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithErrorText(desiredResolution, errorMessage)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithMetadata(channel, None)
.WithFormat("mpegts");
duration.IfSome(d => builder = builder.WithDuration(d));
@@ -149,7 +149,7 @@ namespace ErsatzTV.Core.FFmpeg
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithInfiniteLoop()
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
.WithMetadata(channel)
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
+19 -8
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -13,12 +14,15 @@ namespace ErsatzTV.Core.FFmpeg
public class FFmpegStreamSelector : IFFmpegStreamSelector
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ISearchRepository _searchRepository;
private readonly ILogger<FFmpegStreamSelector> _logger;
public FFmpegStreamSelector(
ISearchRepository searchRepository,
ILogger<FFmpegStreamSelector> logger,
IConfigElementRepository configElementRepository)
{
_searchRepository = searchRepository;
_logger = logger;
_configElementRepository = configElementRepository;
}
@@ -54,24 +58,31 @@ namespace ErsatzTV.Core.FFmpeg
});
}
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
if (allCodes.Count > 1)
{
_logger.LogDebug("Preferred language has multiple codes {Codes}", allCodes);
}
var correctLanguage = audioStreams.Filter(
s => string.Equals(
s.Language,
language,
StringComparison.InvariantCultureIgnoreCase)).ToList();
s => allCodes.Any(
c => string.Equals(
s.Language,
c,
StringComparison.InvariantCultureIgnoreCase))).ToList();
if (correctLanguage.Any())
{
_logger.LogDebug(
"Found {Count} audio streams with preferred language code {Code}; selecting stream with most channels",
"Found {Count} audio streams with preferred language code(s) {Code}; selecting stream with most channels",
correctLanguage.Count,
language);
allCodes);
return correctLanguage.OrderByDescending(s => s.Channels).Head();
}
_logger.LogDebug(
"Unable to find audio stream with preferred language code {Code}; selecting stream with most channels",
language);
"Unable to find audio stream with preferred language code(s) {Code}; selecting stream with most channels",
allCodes);
return audioStreams.OrderByDescending(s => s.Channels).Head();
}
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library);
PlexLibrary library,
string ffprobePath);
}
}
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library);
PlexLibrary library,
string ffprobePath);
}
}
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId);
Task<bool> AddShow(EmbyShow show);
Task<Option<EmbyShow>> Update(EmbyShow show);
Task<bool> AddSeason(EmbySeason season);
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
Task<Unit> Update(EmbySeason season);
Task<bool> AddEpisode(EmbyEpisode episode);
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
Task<bool> AddShow(JellyfinShow show);
Task<Option<JellyfinShow>> Update(JellyfinShow show);
Task<bool> AddSeason(JellyfinSeason season);
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
Task<Unit> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinEpisode episode);
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
@@ -1,11 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILogRepository
{
Task<List<LogEntry>> GetRecentLogEntries();
}
}
@@ -7,9 +7,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ISearchRepository
{
public Task<List<int>> GetItemIdsToIndex();
public Task<Option<MediaItem>> GetItemToIndex(int id);
public Task<List<string>> GetLanguagesForShow(Show show);
public Task<List<string>> GetLanguagesForArtist(Artist artist);
Task<List<int>> GetItemIdsToIndex();
Task<Option<MediaItem>> GetItemToIndex(int id);
Task<List<string>> GetLanguagesForShow(Show show);
Task<List<string>> GetLanguagesForArtist(Artist artist);
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
}
}
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(incoming);
await _televisionRepository.AddSeason(show, incoming);
});
List<JellyfinItemEtag> existingEpisodes =
@@ -365,7 +364,6 @@ namespace ErsatzTV.Core.Jellyfin
try
{
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
@@ -374,7 +372,7 @@ namespace ErsatzTV.Core.Jellyfin
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming))
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
@@ -161,13 +161,15 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Title = match.Groups[1].Value;
metadata.Title = match.Groups[1].Value.Trim();
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.Actors = new List<Actor>();
metadata.Directors = new List<Director>();
metadata.Writers = new List<Writer>();
metadata.DateUpdated = DateTime.UtcNow;
}
}
@@ -194,9 +194,15 @@ namespace ErsatzTV.Core.Metadata
MediaStreamKind = MediaStreamKind.Video,
Index = videoStream.index,
Codec = videoStream.codec_name,
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant(),
PixelFormat = (videoStream.pix_fmt ?? string.Empty).ToLowerInvariant(),
};
if (int.TryParse(videoStream.bits_per_raw_sample, out int bitsPerRawSample))
{
stream.BitsPerRawSample = bitsPerRawSample;
}
if (videoStream.disposition is not null)
{
stream.Default = videoStream.disposition.@default == 1;
@@ -262,8 +268,10 @@ namespace ErsatzTV.Core.Metadata
int height,
string sample_aspect_ratio,
string display_aspect_ratio,
string pix_fmt,
string field_order,
string r_frame_rate,
string bits_per_raw_sample,
FFprobeDisposition disposition,
FFProbeTags tags);
// ReSharper restore InconsistentNaming
+37 -28
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -18,6 +17,7 @@ namespace ErsatzTV.Core.Plex
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
@@ -38,6 +38,7 @@ namespace ErsatzTV.Core.Plex
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -50,13 +51,15 @@ namespace ErsatzTV.Core.Plex
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library)
PlexLibrary library,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
@@ -95,7 +98,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -144,11 +147,10 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
string ffprobePath)
{
PlexMovie existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
@@ -156,30 +158,37 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async tuple =>
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await refreshResult.Match(
async _ =>
{
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Statistics",
existingVersion.MediaFiles.Head().Path);
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
_ => Task.CompletedTask);
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
return result;
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Plex
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
@@ -40,6 +41,7 @@ namespace ErsatzTV.Core.Plex
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -52,13 +54,15 @@ namespace ErsatzTV.Core.Plex
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library)
PlexLibrary library,
string ffprobePath)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
@@ -85,7 +89,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
if (result.IsAdded)
{
@@ -288,7 +292,8 @@ namespace ErsatzTV.Core.Plex
List<PlexPathReplacement> pathReplacements,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
library,
@@ -309,7 +314,13 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
async season => await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffprobePath),
error =>
{
_logger.LogWarning(
@@ -373,7 +384,8 @@ namespace ErsatzTV.Core.Plex
List<PlexPathReplacement> pathReplacements,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
library,
@@ -414,11 +426,13 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
pathReplacements,
existing,
incoming,
library,
connection,
token))
token,
ffprobePath))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeEpisode.Match(
@@ -484,57 +498,89 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
PlexEpisode existing,
PlexEpisode incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
PlexServerAuthToken token,
string ffprobePath)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async tuple =>
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await refreshResult.Match(
async _ =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
await maybeStatistics.Match(
async tuple =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
},
_ => Task.CompletedTask);
},
_ => Task.CompletedTask);
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
return Right<BaseError, PlexEpisode>(existing);
+33 -5
View File
@@ -52,6 +52,15 @@ namespace ErsatzTV.Core.Scheduling
.Distinct()
.ToList();
if (!collectionKeys.Any())
{
_logger.LogWarning(
"Playout {Playout} schedule {Schedule} has no items",
playout.Channel.Name,
playout.ProgramSchedule.Name);
return playout;
}
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(
async collectionKey =>
{
@@ -192,6 +201,7 @@ namespace ErsatzTV.Core.Scheduling
// start with the previous multiple/duration states
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
bool inFlood = startAnchor.InFlood;
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
@@ -206,7 +216,8 @@ namespace ErsatzTV.Core.Scheduling
scheduleItem,
currentTime,
multipleRemaining.IsSome,
durationFinish.IsSome);
durationFinish.IsSome,
inFlood);
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
await enumerator.Current.IfSomeAsync(
@@ -259,7 +270,16 @@ namespace ErsatzTV.Core.Scheduling
case ProgramScheduleItemMultiple multiple:
if (multipleRemaining.IsNone)
{
multipleRemaining = multiple.Count;
if (multiple.Count == 0)
{
multipleRemaining = collectionMediaItems[CollectionKeyForItem(scheduleItem)]
.Count;
}
else
{
multipleRemaining = multiple.Count;
}
customGroup = true;
}
@@ -309,6 +329,11 @@ namespace ErsatzTV.Core.Scheduling
"Flood");
index++;
customGroup = false;
inFlood = false;
}
else
{
inFlood = true;
}
});
break;
@@ -365,7 +390,8 @@ namespace ErsatzTV.Core.Scheduling
NextScheduleItemId = nextScheduleItem.Id,
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
InFlood = inFlood
};
// build program schedule anchors
@@ -410,13 +436,15 @@ namespace ErsatzTV.Core.Scheduling
ProgramScheduleItem item,
DateTimeOffset start,
bool inMultiple = false,
bool inDuration = false)
bool inDuration = false,
bool inFlood = false)
{
switch (item.StartType)
{
case StartType.Fixed:
if (item is ProgramScheduleItemMultiple && inMultiple ||
item is ProgramScheduleItemDuration && inDuration)
item is ProgramScheduleItemDuration && inDuration ||
item is ProgramScheduleItemFlood && inFlood)
{
return start;
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class LanguageCodeConfiguration : IEntityTypeConfiguration<LanguageCode>
{
public void Configure(EntityTypeBuilder<LanguageCode> builder) => builder.ToTable("LanguageCode");
}
}
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -12,6 +14,43 @@ namespace ErsatzTV.Infrastructure.Data
{
public static async Task<Unit> Initialize(TvContext context, CancellationToken cancellationToken)
{
if (!context.LanguageCodes.Any())
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
await using Stream resource =
assembly.GetManifestResourceStream("ErsatzTV.Resources.ISO-639-2_utf-8.txt");
if (resource != null)
{
using var reader = new StreamReader(resource);
while (!reader.EndOfStream)
{
string line = await reader.ReadLineAsync();
if (line != null)
{
string[] split = line.Split("|");
if (split.Length == 5)
{
var languageCode = new LanguageCode
{
ThreeCode1 = split[0],
ThreeCode2 = split[1],
TwoCode = split[2],
EnglishName = split[3],
FrenchName = split[4]
};
await context.LanguageCodes.AddAsync(languageCode, cancellationToken);
}
}
}
}
}
await context.SaveChangesAsync(cancellationToken);
}
if (context.Resolutions.Any())
{
return Unit.Default;
@@ -242,18 +242,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public async Task<bool> AddSeason(EmbySeason season)
public async Task<bool> AddSeason(EmbyShow show, EmbySeason season)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
new { show.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Unit> Update(EmbySeason season)
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public async Task<bool> AddEpisode(EmbyEpisode episode)
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
new { season.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
@@ -259,18 +259,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public async Task<bool> AddSeason(JellyfinSeason season)
public async Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
new { show.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Unit> Update(JellyfinSeason season)
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public async Task<bool> AddEpisode(JellyfinEpisode episode)
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
try
{
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
new { season.ItemId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
@@ -1,19 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class LogRepository : ILogRepository
{
private readonly LogContext _logContext;
public LogRepository(LogContext logContext) => _logContext = logContext;
public Task<List<LogEntry>> GetRecentLogEntries() =>
_logContext.LogEntries.OrderByDescending(e => e.Id).Take(100).ToListAsync();
}
}
@@ -166,25 +166,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
() => Task.FromResult(false));
}
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
{
bool updatedVersion = await _dbConnection.ExecuteAsync(
public Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming) =>
_dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind,
DateUpdated = @DateUpdated
WHERE Id = @MediaVersionId",
new
{
incoming.SampleAspectRatio,
incoming.VideoScanKind,
incoming.DateUpdated,
MediaVersionId = mediaVersionId
}).Map(result => result > 0);
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
}
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
@@ -110,5 +111,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
INNER JOIN Artist A on MV.ArtistId = A.Id
WHERE MediaStreamKind = 2 AND A.Id = @ArtistId",
new { ArtistId = artist.Id }).Map(result => result.ToList());
public async Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes);
}
}
}
@@ -65,6 +65,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<PlayoutProgramScheduleAnchor> PlayoutProgramScheduleItemAnchors { get; set; }
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; }
public DbSet<LanguageCode> LanguageCodes { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory);
@@ -13,16 +13,16 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Dapper" Version="2.0.90" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.6" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Extensions
{
public static class LanguageCodeQueryableExtensions
{
public static async Task<List<string>> GetAllLanguageCodes(
this IQueryable<LanguageCode> languageCodes,
string mediaCode)
{
if (string.IsNullOrWhiteSpace(mediaCode))
{
return new List<string>();
}
string code = mediaCode.ToLowerInvariant();
List<LanguageCode> maybeLanguages = await languageCodes
.Filter(lc => lc.ThreeCode1 == code || lc.ThreeCode2 == code)
.ToListAsync();
var result = new HashSet<string>();
foreach (LanguageCode language in maybeLanguages)
{
if (!string.IsNullOrWhiteSpace(language.ThreeCode1))
{
result.Add(language.ThreeCode1);
}
if (!string.IsNullOrWhiteSpace(language.ThreeCode2))
{
result.Add(language.ThreeCode2);
}
}
return result.ToList();
}
public static async Task<List<string>> GetAllLanguageCodes(
this IQueryable<LanguageCode> languageCodes,
List<string> mediaCodes)
{
var validCodes = mediaCodes
.Filter(c => !string.IsNullOrWhiteSpace(c))
.Map(c => c.ToLowerInvariant())
.ToList();
if (validCodes.Count == 0)
{
return new List<string>();
}
List<LanguageCode> maybeLanguages = await languageCodes
.Filter(lc => validCodes.Contains(lc.ThreeCode1) || validCodes.Contains(lc.ThreeCode2))
.ToListAsync();
var result = new HashSet<string>();
foreach (LanguageCode language in maybeLanguages)
{
if (!string.IsNullOrWhiteSpace(language.ThreeCode1))
{
result.Add(language.ThreeCode1);
}
if (!string.IsNullOrWhiteSpace(language.ThreeCode2))
{
result.Add(language.ThreeCode2);
}
}
return result.ToList();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_LanguageCode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LanguageCode",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ThreeCode1 = table.Column<string>(type: "TEXT", nullable: true),
ThreeCode2 = table.Column<string>(type: "TEXT", nullable: true),
TwoCode = table.Column<string>(type: "TEXT", nullable: true),
EnglishName = table.Column<string>(type: "TEXT", nullable: true),
FrenchName = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LanguageCode", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LanguageCode");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaStreamPixelFormat : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BitsPerRawSample",
table: "MediaStream",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "PixelFormat",
table: "MediaStream",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BitsPerRawSample",
table: "MediaStream");
migrationBuilder.DropColumn(
name: "PixelFormat",
table: "MediaStream");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_AllStatistics : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlayoutAnchorInFlood : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Anchor_InFlood",
table: "Playout",
type: "INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Anchor_InFlood",
table: "Playout");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Fix_PlayoutAnchorInFlood : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Playout SET Anchor_InFlood = 0 WHERE Anchor_InFlood IS NULL");
migrationBuilder.AlterColumn<bool>(
name: "Anchor_InFlood",
table: "Playout",
type: "INTEGER",
nullable: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Fix_MovieFallbackMetadataTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"UPDATE MovieMetadata SET Title = TRIM(Title), SortTitle = TRIM(SortTitle) WHERE MetadataKind = 0");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.6");
.HasAnnotation("ProductVersion", "5.0.7");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -583,6 +583,32 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("JellyfinPathReplacement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LanguageCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("EnglishName")
.HasColumnType("TEXT");
b.Property<string>("FrenchName")
.HasColumnType("TEXT");
b.Property<string>("ThreeCode1")
.HasColumnType("TEXT");
b.Property<string>("ThreeCode2")
.HasColumnType("TEXT");
b.Property<string>("TwoCode")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("LanguageCode");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
{
b.Property<int>("Id")
@@ -707,6 +733,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BitsPerRawSample")
.HasColumnType("INTEGER");
b.Property<int>("Channels")
.HasColumnType("INTEGER");
@@ -731,6 +760,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("MediaVersionId")
.HasColumnType("INTEGER");
b.Property<string>("PixelFormat")
.HasColumnType("TEXT");
b.Property<string>("Profile")
.HasColumnType("TEXT");
@@ -2157,6 +2189,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b1.Property<DateTime?>("DurationFinish")
.HasColumnType("TEXT");
b1.Property<bool>("InFlood")
.HasColumnType("INTEGER");
b1.Property<int?>("MultipleRemaining")
.HasColumnType("INTEGER");
+69 -82
View File
@@ -72,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 13;
public int Version => 14;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -91,41 +91,6 @@ namespace ErsatzTV.Infrastructure.Search
return Task.FromResult(_initialized);
}
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
{
_writer.DeleteAll();
foreach (int id in itemIds)
{
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
UpdateMovie(movie);
break;
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
break;
}
}
}
_writer.Commit();
return Unit.Default;
}
public Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items) =>
UpdateItems(searchRepository, items);
@@ -136,7 +101,7 @@ namespace ErsatzTV.Infrastructure.Search
switch (item)
{
case Movie movie:
UpdateMovie(movie);
await UpdateMovie(searchRepository, movie);
break;
case Show show:
await UpdateShow(searchRepository, show);
@@ -145,10 +110,10 @@ namespace ErsatzTV.Infrastructure.Search
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
await UpdateMusicVideo(searchRepository, musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
await UpdateEpisode(searchRepository, episode);
break;
}
}
@@ -217,6 +182,41 @@ namespace ErsatzTV.Infrastructure.Search
_directory?.Dispose();
}
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
{
_writer.DeleteAll();
foreach (int id in itemIds)
{
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
await UpdateMovie(searchRepository, movie);
break;
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
await UpdateMusicVideo(searchRepository, musicVideo);
break;
case Episode episode:
await UpdateEpisode(searchRepository, episode);
break;
}
}
}
_writer.Commit();
return Unit.Default;
}
private static Option<SearchPageMap> GetSearchPageMap(
IndexSearcher searcher,
Query query,
@@ -263,7 +263,7 @@ namespace ErsatzTV.Infrastructure.Search
return new SearchPageMap(map);
}
private void UpdateMovie(Movie movie)
private async Task UpdateMovie(ISearchRepository searchRepository, Movie movie)
{
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -284,7 +284,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, movie.MediaVersions);
await AddLanguages(searchRepository, doc, movie.MediaVersions);
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
{
@@ -349,25 +349,38 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void AddLanguages(Document doc, List<MediaVersion> mediaVersions)
private async Task AddLanguages(ISearchRepository searchRepository, Document doc, List<MediaVersion> mediaVersions)
{
Option<MediaVersion> maybeVersion = mediaVersions.HeadOrNone();
if (maybeVersion.IsSome)
{
MediaVersion version = maybeVersion.ValueUnsafe();
foreach (CultureInfo cultureInfo in version.Streams
var mediaCodes = version.Streams
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
.Map(ms => ms.Language).Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(c.ThreeLetterISOLanguageName, l, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
.ToList();
await AddLanguages(searchRepository, doc, mediaCodes);
}
}
private async Task AddLanguages(ISearchRepository searchRepository, Document doc, List<string> mediaCodes)
{
var englishNames = new System.Collections.Generic.HashSet<string>();
foreach (string code in await searchRepository.GetAllLanguageCodes(mediaCodes))
{
Option<CultureInfo> maybeCultureInfo = _cultureInfos.Find(
ci => string.Equals(ci.ThreeLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase));
foreach (CultureInfo cultureInfo in maybeCultureInfo)
{
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
englishNames.Add(cultureInfo.EnglishName);
}
}
foreach (string englishName in englishNames)
{
doc.Add(new TextField(LanguageField, englishName, Field.Store.NO));
}
}
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
@@ -392,20 +405,7 @@ namespace ErsatzTV.Infrastructure.Search
};
List<string> languages = await searchRepository.GetLanguagesForShow(show);
foreach (CultureInfo cultureInfo in languages
.Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(
c.ThreeLetterISOLanguageName,
l,
StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
{
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
}
await AddLanguages(searchRepository, doc, languages);
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
{
@@ -482,20 +482,7 @@ namespace ErsatzTV.Infrastructure.Search
};
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
foreach (CultureInfo cultureInfo in languages
.Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(
c.ThreeLetterISOLanguageName,
l,
StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
{
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
}
await AddLanguages(searchRepository, doc, languages);
foreach (Genre genre in metadata.Genres)
{
@@ -522,7 +509,7 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateMusicVideo(MusicVideo musicVideo)
private async Task UpdateMusicVideo(ISearchRepository searchRepository, MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -543,7 +530,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, musicVideo.MediaVersions);
await AddLanguages(searchRepository, doc, musicVideo.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
@@ -584,7 +571,7 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateEpisode(Episode episode)
private async Task UpdateEpisode(ISearchRepository searchRepository, Episode episode)
{
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
@@ -611,7 +598,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO));
doc.Add(new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES));
AddLanguages(doc, episode.MediaVersions);
await AddLanguages(searchRepository, doc, episode.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
+10 -8
View File
@@ -17,24 +17,25 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.1" />
<PackageReference Include="FluentValidation" Version="10.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.1.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.1.2" />
<PackageReference Include="FluentValidation" Version="10.3.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.0" />
<PackageReference Include="HtmlSanitizer" Version="5.0.404" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Markdig" Version="0.24.0" />
<PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.0.10" />
<PackageReference Include="MudBlazor" Version="5.0.15" />
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
<PackageReference Include="Serilog" Version="2.10.0" />
@@ -53,6 +54,7 @@
<EmbeddedResource Include="Resources\background.png" />
<EmbeddedResource Include="Resources\ErsatzTV.png" />
<EmbeddedResource Include="Resources\Roboto-Regular.ttf" />
<EmbeddedResource Include="Resources\ISO-639-2_utf-8.txt" />
</ItemGroup>
</Project>
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Extensions
{
public static class StringExtensions
{
public static string GetSearchQuery(this string uri)
{
try
{
string query = new Uri(uri).Query;
Dictionary<string, StringValues> parsed = QueryHelpers.ParseQuery(query);
if (parsed.TryGetValue("query", out StringValues value))
{
return value;
}
if (parsed.TryGetValue("b64query", out StringValues base64Value))
{
return base64Value.DecodeBase64();
}
}
catch (Exception)
{
// do nothing
}
return string.Empty;
}
public static string GetRelativeSearchQuery(this string query)
{
(string key, string value) = EncodeQuery(query);
return $"/search?{key}={value}";
}
private static string DecodeBase64(this StringValues input) =>
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(input));
public static EncodedQueryResult EncodeQuery(this string query)
{
string encoded = Uri.EscapeDataString(query);
// TODO: remove this on dotnet 6
// see https://github.com/dotnet/aspnetcore/pull/26769
var fakeAbsolute = $"https://whatever.com/test?query={encoded}";
return Uri.IsWellFormedUriString(fakeAbsolute, UriKind.Absolute)
? new EncodedQueryResult("query", encoded)
: new EncodedQueryResult("b64query", WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(query)));
}
public record EncodedQueryResult(string Key, string Value);
}
}
+9 -8
View File
@@ -8,6 +8,7 @@
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using System.Globalization
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inject IMediator _mediator
@inject IDialogService _dialog
@@ -73,11 +74,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@@ -85,11 +86,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@@ -97,11 +98,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Styles:&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(_sortedStyles.Head())}%22")">@_sortedStyles.Head()</MudLink>
<MudLink Href="@(@$"style:""{_sortedStyles.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStyles.Head()</MudLink>
@foreach (string style in _sortedStyles.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(style)}%22")">@style</MudLink>
<MudLink Href="@(@$"style:""{style.ToLowerInvariant()}""".GetRelativeSearchQuery())">@style</MudLink>
}
</div>
}
@@ -109,11 +110,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Moods:&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(_sortedMoods.Head())}%22")">@_sortedMoods.Head()</MudLink>
<MudLink Href="@(@$"mood:""{_sortedMoods.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedMoods.Head()</MudLink>
@foreach (string mood in _sortedMoods.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood)}%22")">@mood</MudLink>
<MudLink Href="@(@$"mood:""{mood.ToLowerInvariant()}""".GetRelativeSearchQuery())">@mood</MudLink>
}
</div>
}
+6 -13
View File
@@ -2,11 +2,11 @@
@page "/media/music/artists/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MusicVideoList>
@inject NavigationManager _navigationManager
@@ -90,16 +90,7 @@
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
@@ -114,7 +105,8 @@
var uri = $"/media/music/artists/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
@@ -124,7 +116,8 @@
var uri = $"/media/music/artists/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
+6 -13
View File
@@ -2,11 +2,11 @@
@page "/media/tv/episodes/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<EpisodeList>
@inject NavigationManager _navigationManager
@@ -90,16 +90,7 @@
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
@@ -114,7 +105,8 @@
var uri = $"/media/tv/episodes/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
@@ -124,7 +116,8 @@
var uri = $"/media/tv/episodes/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
+50 -5
View File
@@ -1,18 +1,24 @@
@page "/system/logs"
@using ErsatzTV.Application.Logs
@using ErsatzTV.Application.Logs.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.Configuration.Commands
@inject IMediator _mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
<MudTable FixedHeader="true"
@bind-RowsPerPage="@_rowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<LogEntryViewModel>>>(ServerReload))"
Dense="true"
@ref="_table">
<HeaderContent>
<MudTh>
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Timestamp)">
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Timestamp">
Timestamp
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Level)">
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Level">
Level
</MudTableSortLabel>
</MudTh>
@@ -30,8 +36,47 @@
</MudContainer>
@code {
private List<LogEntryViewModel> _logEntries;
private MudTable<LogEntryViewModel> _table;
private int _rowsPerPage;
protected override async Task OnInitializedAsync() => _logEntries = await _mediator.Send(new GetRecentLogEntries());
protected override async Task OnParametersSetAsync() => _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
private async Task<TableData<LogEntryViewModel>> ServerReload(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString()));
PagedLogEntriesViewModel data;
switch (state.SortLabel?.ToLowerInvariant())
{
case "timestamp":
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
{
SortExpression = le => le.Timestamp,
SortDescending = state.SortDirection == SortDirection.None
? Option<bool>.None
: state.SortDirection == SortDirection.Descending
});
break;
case "level":
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
{
SortExpression = le => le.Level,
SortDescending = state.SortDirection == SortDirection.None
? Option<bool>.None
: state.SortDirection == SortDirection.Descending
});
break;
default:
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
{
SortDescending = Option<bool>.None
});
break;
}
return new TableData<LogEntryViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
}
+16 -15
View File
@@ -5,6 +5,7 @@
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Extensions
@inject IMediator _mediator
@inject IDialogService _dialog
@inject NavigationManager _navigationManager
@@ -67,11 +68,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Content Ratings:&nbsp;</MudText>
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
@foreach (string contentRating in _sortedContentRatings.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
}
</div>
}
@@ -79,11 +80,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@@ -91,11 +92,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
}
</div>
}
@@ -103,11 +104,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Directors:&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(_sortedDirectors.Head())}%22")">@_sortedDirectors.Head()</MudLink>
<MudLink Href="@(@$"director:""{_sortedDirectors.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedDirectors.Head()</MudLink>
@foreach (string director in _sortedDirectors.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
}
</div>
}
@@ -115,11 +116,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Writers:&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(_sortedWriters.Head())}%22")">@_sortedWriters.Head()</MudLink>
<MudLink Href="@(@$"writer:""{_sortedWriters.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedWriters.Head()</MudLink>
@foreach (string writer in _sortedWriters.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
}
</div>
}
@@ -127,11 +128,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@@ -139,11 +140,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
}
</div>
}
@@ -159,7 +160,7 @@
@foreach (ActorCardViewModel actor in _movie.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
Link="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
+6 -13
View File
@@ -2,11 +2,11 @@
@page "/media/movies/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MovieList>
@inject NavigationManager _navigationManager
@@ -89,16 +89,7 @@
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
@@ -113,7 +104,8 @@
var uri = $"/media/movies/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
@@ -123,7 +115,8 @@
var uri = $"/media/movies/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
+6 -13
View File
@@ -2,11 +2,11 @@
@page "/media/music/videos/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MusicVideoList>
@inject NavigationManager _navigationManager
@@ -90,16 +90,7 @@
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
@@ -114,7 +105,8 @@
var uri = $"/media/music/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
@@ -124,7 +116,8 @@
var uri = $"/media/music/videos/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
+34 -4
View File
@@ -63,9 +63,14 @@
Add Playout
</MudButton>
@if (_selectedPlayoutItems != null)
@if (_selectedPlayoutId != null)
{
<MudTable Hover="true" Dense="true" Items="_selectedPlayoutItems.OrderBy(i => i.Start)" Class="mt-8">
<MudTable Class="mt-8"
Hover="true"
Dense="true"
@bind-RowsPerPage="@_detailRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<PlayoutItemViewModel>>>(DetailServerReload))"
@ref="_detailTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Playout Detail</MudText>
</ToolBarContent>
@@ -88,20 +93,23 @@
@code {
private MudTable<PlayoutNameViewModel> _table;
private MudTable<PlayoutItemViewModel> _detailTable;
private int _rowsPerPage;
private List<PlayoutItemViewModel> _selectedPlayoutItems;
private int _detailRowsPerPage;
private int? _selectedPlayoutId;
protected override async Task OnParametersSetAsync()
{
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private async Task PlayoutSelected(PlayoutNameViewModel playout)
{
_selectedPlayoutId = playout.PlayoutId;
_selectedPlayoutItems = await _mediator.Send(new GetPlayoutItemsById(playout.PlayoutId));
await _detailTable.ReloadServerData();
}
private async Task DeletePlayout(PlayoutNameViewModel playout)
@@ -115,6 +123,10 @@
{
await _mediator.Send(new DeletePlayout(playout.PlayoutId));
await _table.ReloadServerData();
if (_selectedPlayoutId == playout.PlayoutId)
{
_selectedPlayoutId = null;
}
}
}
@@ -143,4 +155,22 @@
};
}
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()));
if (_selectedPlayoutId.HasValue)
{
PagedPlayoutItemsViewModel data =
await _mediator.Send(new GetPlayoutItemsById(_selectedPlayoutId.Value, state.Page, state.PageSize));
return new TableData<PlayoutItemViewModel>
{
TotalItems = data.TotalCount,
Items = data.Page
};
}
return new TableData<PlayoutItemViewModel> { TotalItems = 0 };
}
}
+9 -8
View File
@@ -71,9 +71,6 @@
</MudIconButton>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddScheduleItem())" Class="mt-4">
Add Schedule Item
@@ -167,7 +164,7 @@
<MudText Class="mt-3 mb-2" Typo="Typo.h6">Playout Mode</MudText>
<ul class="mud-typography-body1">
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item. A count of <b>0</b> is a special case that will automatically and always equal the number of items in the collection, so each item from the collection will be played once before advancing.</li>
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
</ul>
@@ -196,10 +193,14 @@
private async Task LoadScheduleItems()
{
// TODO: fix performance
_mediaCollections = await _mediator.Send(new GetAllCollections());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons());
_artists = await _mediator.Send(new GetAllArtists());
_mediaCollections = await _mediator.Send(new GetAllCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_artists = await _mediator.Send(new GetAllArtists())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
string name = string.Empty;
Option<ProgramScheduleViewModel> maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id));
+32 -6
View File
@@ -4,6 +4,7 @@
@using ErsatzTV.Application.ProgramSchedules.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.Configuration.Commands
@using NaturalSort.Extension
@inject IDialogService _dialog
@inject IMediator _mediator
@@ -62,9 +63,13 @@
Add Schedule
</MudButton>
@if (_selectedScheduleItems != null)
@if (_selectedSchedule != null)
{
<MudTable Hover="true" Items="_selectedScheduleItems.OrderBy(i => i.Index)" Class="mt-8">
<MudTable Hover="true"
Class="mt-8"
@bind-RowsPerPage="@_detailRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<ProgramScheduleItemViewModel>>>(DetailServerReload))"
@ref="_detailTable">
<ToolBarContent>
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
</ToolBarContent>
@@ -89,21 +94,23 @@
@code {
private MudTable<ProgramScheduleViewModel> _table;
private MudTable<ProgramScheduleItemViewModel> _detailTable;
private int _rowsPerPage;
private List<ProgramScheduleItemViewModel> _selectedScheduleItems;
private int _detailRowsPerPage;
private ProgramScheduleViewModel _selectedSchedule;
protected override async Task OnParametersSetAsync()
{
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private async Task ScheduleSelected(ProgramScheduleViewModel schedule)
{
_selectedSchedule = schedule;
await _mediator.Send(new GetProgramScheduleItems(schedule.Id))
.Map(results => _selectedScheduleItems = results.OrderBy(x => x.Name).ToList());
await _detailTable.ReloadServerData();
}
private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule)
@@ -117,6 +124,10 @@
{
await _mediator.Send(new DeleteProgramSchedule(programSchedule.Id));
await _table.ReloadServerData();
if (_selectedSchedule == programSchedule)
{
_selectedSchedule = null;
}
}
}
@@ -125,7 +136,7 @@
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()));
List<ProgramScheduleViewModel> schedules = await _mediator.Send(new GetAllProgramSchedules());
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name);
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase));
// TODO: properly page this data
return new TableData<ProgramScheduleViewModel>
@@ -135,4 +146,19 @@
};
}
private async Task<TableData<ProgramScheduleItemViewModel>> DetailServerReload(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()));
List<ProgramScheduleItemViewModel> scheduleItems = await _mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id));
IOrderedEnumerable<ProgramScheduleItemViewModel> sorted = scheduleItems.OrderBy(s => s.Index);
// TODO: properly page this data
return new TableData<ProgramScheduleItemViewModel>
{
TotalItems = scheduleItems.Count,
Items = sorted.Skip(state.Page * state.PageSize).Take(state.PageSize)
};
}
}
+23 -21
View File
@@ -4,8 +4,8 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<Search>
@inject NavigationManager _navigationManager
@@ -35,27 +35,27 @@
else
{
<MudText Style="margin-bottom: auto; margin-top: auto">@_query</MudText>
if (_movies.Count > 0)
if (_movies?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
if (_shows.Count > 0)
if (_shows?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
if (_episodes.Count > 0)
if (_episodes?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
}
if (_artists.Count > 0)
if (_artists?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
if (_musicVideos.Count > 0)
if (_musicVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
@@ -71,7 +71,7 @@
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_movies.Count > 0)
@if (_movies?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
@@ -98,7 +98,7 @@
</MudContainer>
}
@if (_shows.Count > 0)
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
@@ -125,7 +125,7 @@
</MudContainer>
}
@if (_episodes.Count > 0)
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
@@ -153,7 +153,7 @@
</MudContainer>
}
@if (_artists.Count > 0)
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
@@ -181,7 +181,7 @@
</MudContainer>
}
@if (_musicVideos.Count > 0)
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
@@ -220,12 +220,9 @@
protected override async Task OnInitializedAsync()
{
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
_query = _navigationManager.Uri.GetSearchQuery();
if (!string.IsNullOrWhiteSpace(_query))
{
_query = value;
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
@@ -362,7 +359,8 @@
var uri = "/media/movies/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
@@ -372,7 +370,8 @@
var uri = "/media/tv/shows/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
@@ -382,7 +381,8 @@
var uri = "/media/tv/episodes/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
@@ -392,7 +392,8 @@
var uri = "/media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
@@ -402,7 +403,8 @@
var uri = "/media/music/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
+4 -4
View File
@@ -116,11 +116,11 @@
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Directors:&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
<MudLink Href="@(@$"director:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
@foreach (string director in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
}
</div>
}
@@ -130,11 +130,11 @@
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Writers:&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
<MudLink Href="@(@$"writer:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
@foreach (string writer in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
}
</div>
}
+12 -11
View File
@@ -8,6 +8,7 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inject IMediator _mediator
@inject ILogger<TelevisionSeasonList> _logger
@@ -81,11 +82,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Content Ratings:&nbsp;</MudText>
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
@foreach (string contentRating in _sortedContentRatings.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
}
</div>
}
@@ -93,11 +94,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@@ -105,11 +106,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
}
</div>
}
@@ -117,11 +118,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@@ -129,11 +130,11 @@
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
}
</div>
}
@@ -160,7 +161,7 @@
@foreach (ActorCardViewModel actor in _show.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
Link="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
+6 -13
View File
@@ -2,11 +2,11 @@
@page "/media/tv/shows/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager _navigationManager
@@ -89,16 +89,7 @@
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
@@ -113,7 +104,8 @@
var uri = $"/media/tv/shows/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
@@ -123,7 +115,8 @@
var uri = $"/media/tv/shows/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
+487
View File
@@ -0,0 +1,487 @@
aar||aa|Afar|afar
abk||ab|Abkhazian|abkhaze
ace|||Achinese|aceh
ach|||Acoli|acoli
ada|||Adangme|adangme
ady|||Adyghe; Adygei|adyghé
afa|||Afro-Asiatic languages|afro-asiatiques, langues
afh|||Afrihili|afrihili
afr||af|Afrikaans|afrikaans
ain|||Ainu|aïnou
aka||ak|Akan|akan
akk|||Akkadian|akkadien
alb|sqi|sq|Albanian|albanais
ale|||Aleut|aléoute
alg|||Algonquian languages|algonquines, langues
alt|||Southern Altai|altai du Sud
amh||am|Amharic|amharique
ang|||English, Old (ca.450-1100)|anglo-saxon (ca.450-1100)
anp|||Angika|angika
apa|||Apache languages|apaches, langues
ara||ar|Arabic|arabe
arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE)
arg||an|Aragonese|aragonais
arm|hye|hy|Armenian|arménien
arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce
arp|||Arapaho|arapaho
art|||Artificial languages|artificielles, langues
arw|||Arawak|arawak
asm||as|Assamese|assamais
ast|||Asturian; Bable; Leonese; Asturleonese|asturien; bable; léonais; asturoléonais
ath|||Athapascan languages|athapascanes, langues
aus|||Australian languages|australiennes, langues
ava||av|Avaric|avar
ave||ae|Avestan|avestique
awa|||Awadhi|awadhi
aym||ay|Aymara|aymara
aze||az|Azerbaijani|azéri
bad|||Banda languages|banda, langues
bai|||Bamileke languages|bamiléké, langues
bak||ba|Bashkir|bachkir
bal|||Baluchi|baloutchi
bam||bm|Bambara|bambara
ban|||Balinese|balinais
baq|eus|eu|Basque|basque
bas|||Basa|basa
bat|||Baltic languages|baltes, langues
bej|||Beja; Bedawiyet|bedja
bel||be|Belarusian|biélorusse
bem|||Bemba|bemba
ben||bn|Bengali|bengali
ber|||Berber languages|berbères, langues
bho|||Bhojpuri|bhojpuri
bih||bh|Bihari languages|langues biharis
bik|||Bikol|bikol
bin|||Bini; Edo|bini; edo
bis||bi|Bislama|bichlamar
bla|||Siksika|blackfoot
bnt|||Bantu languages|bantou, langues
bos||bs|Bosnian|bosniaque
bra|||Braj|braj
bre||br|Breton|breton
btk|||Batak languages|batak, langues
bua|||Buriat|bouriate
bug|||Buginese|bugi
bul||bg|Bulgarian|bulgare
bur|mya|my|Burmese|birman
byn|||Blin; Bilin|blin; bilen
cad|||Caddo|caddo
cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues
car|||Galibi Carib|karib; galibi; carib
cat||ca|Catalan; Valencian|catalan; valencien
cau|||Caucasian languages|caucasiennes, langues
ceb|||Cebuano|cebuano
cel|||Celtic languages|celtiques, langues; celtes, langues
cha||ch|Chamorro|chamorro
chb|||Chibcha|chibcha
che||ce|Chechen|tchétchène
chg|||Chagatai|djaghataï
chi|zho|zh|Chinese|chinois
chk|||Chuukese|chuuk
chm|||Mari|mari
chn|||Chinook jargon|chinook, jargon
cho|||Choctaw|choctaw
chp|||Chipewyan; Dene Suline|chipewyan
chr|||Cherokee|cherokee
chu||cu|Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic|slavon d'église; vieux slave; slavon liturgique; vieux bulgare
chv||cv|Chuvash|tchouvache
chy|||Cheyenne|cheyenne
cmc|||Chamic languages|chames, langues
cnr|||Montenegrin|monténégrin
cop|||Coptic|copte
cor||kw|Cornish|cornique
cos||co|Corsican|corse
cpe|||Creoles and pidgins, English based|créoles et pidgins basés sur l'anglais
cpf|||Creoles and pidgins, French-based|créoles et pidgins basés sur le français
cpp|||Creoles and pidgins, Portuguese-based|créoles et pidgins basés sur le portugais
cre||cr|Cree|cree
crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé
crp|||Creoles and pidgins|créoles et pidgins
csb|||Kashubian|kachoube
cus|||Cushitic languages|couchitiques, langues
cze|ces|cs|Czech|tchèque
dak|||Dakota|dakota
dan||da|Danish|danois
dar|||Dargwa|dargwa
day|||Land Dayak languages|dayak, langues
del|||Delaware|delaware
den|||Slave (Athapascan)|esclave (athapascan)
dgr|||Dogrib|dogrib
din|||Dinka|dinka
div||dv|Divehi; Dhivehi; Maldivian|maldivien
doi|||Dogri|dogri
dra|||Dravidian languages|dravidiennes, langues
dsb|||Lower Sorbian|bas-sorabe
dua|||Duala|douala
dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350)
dut|nld|nl|Dutch; Flemish|néerlandais; flamand
dyu|||Dyula|dioula
dzo||dz|Dzongkha|dzongkha
efi|||Efik|efik
egy|||Egyptian (Ancient)|égyptien
eka|||Ekajuk|ekajuk
elx|||Elamite|élamite
eng||en|English|anglais
enm|||English, Middle (1100-1500)|anglais moyen (1100-1500)
epo||eo|Esperanto|espéranto
est||et|Estonian|estonien
ewe||ee|Ewe|éwé
ewo|||Ewondo|éwondo
fan|||Fang|fang
fao||fo|Faroese|féroïen
fat|||Fanti|fanti
fij||fj|Fijian|fidjien
fil|||Filipino; Pilipino|filipino; pilipino
fin||fi|Finnish|finnois
fiu|||Finno-Ugrian languages|finno-ougriennes, langues
fon|||Fon|fon
fre|fra|fr|French|français
frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600)
fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400)
frr|||Northern Frisian|frison septentrional
frs|||Eastern Frisian|frison oriental
fry||fy|Western Frisian|frison occidental
ful||ff|Fulah|peul
fur|||Friulian|frioulan
gaa|||Ga|ga
gay|||Gayo|gayo
gba|||Gbaya|gbaya
gem|||Germanic languages|germaniques, langues
geo|kat|ka|Georgian|géorgien
ger|deu|de|German|allemand
gez|||Geez|guèze
gil|||Gilbertese|kiribati
gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais
gle||ga|Irish|irlandais
glg||gl|Galician|galicien
glv||gv|Manx|manx; mannois
gmh|||German, Middle High (ca.1050-1500)|allemand, moyen haut (ca. 1050-1500)
goh|||German, Old High (ca.750-1050)|allemand, vieux haut (ca. 750-1050)
gon|||Gondi|gond
gor|||Gorontalo|gorontalo
got|||Gothic|gothique
grb|||Grebo|grebo
grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453)
gre|ell|el|Greek, Modern (1453-)|grec moderne (après 1453)
grn||gn|Guarani|guarani
gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien
guj||gu|Gujarati|goudjrati
gwi|||Gwich'in|gwich'in
hai|||Haida|haida
hat||ht|Haitian; Haitian Creole|haïtien; créole haïtien
hau||ha|Hausa|haoussa
haw|||Hawaiian|hawaïen
heb||he|Hebrew|hébreu
her||hz|Herero|herero
hil|||Hiligaynon|hiligaynon
him|||Himachali languages; Western Pahari languages|langues himachalis; langues paharis occidentales
hin||hi|Hindi|hindi
hit|||Hittite|hittite
hmn|||Hmong; Mong|hmong
hmo||ho|Hiri Motu|hiri motu
hrv||hr|Croatian|croate
hsb|||Upper Sorbian|haut-sorabe
hun||hu|Hungarian|hongrois
hup|||Hupa|hupa
iba|||Iban|iban
ibo||ig|Igbo|igbo
ice|isl|is|Icelandic|islandais
ido||io|Ido|ido
iii||ii|Sichuan Yi; Nuosu|yi de Sichuan
ijo|||Ijo languages|ijo, langues
iku||iu|Inuktitut|inuktitut
ile||ie|Interlingue; Occidental|interlingue
ilo|||Iloko|ilocano
ina||ia|Interlingua (International Auxiliary Language Association)|interlingua (langue auxiliaire internationale)
inc|||Indic languages|indo-aryennes, langues
ind||id|Indonesian|indonésien
ine|||Indo-European languages|indo-européennes, langues
inh|||Ingush|ingouche
ipk||ik|Inupiaq|inupiaq
ira|||Iranian languages|iraniennes, langues
iro|||Iroquoian languages|iroquoises, langues
ita||it|Italian|italien
jav||jv|Javanese|javanais
jbo|||Lojban|lojban
jpn||ja|Japanese|japonais
jpr|||Judeo-Persian|judéo-persan
jrb|||Judeo-Arabic|judéo-arabe
kaa|||Kara-Kalpak|karakalpak
kab|||Kabyle|kabyle
kac|||Kachin; Jingpho|kachin; jingpho
kal||kl|Kalaallisut; Greenlandic|groenlandais
kam|||Kamba|kamba
kan||kn|Kannada|kannada
kar|||Karen languages|karen, langues
kas||ks|Kashmiri|kashmiri
kau||kr|Kanuri|kanouri
kaw|||Kawi|kawi
kaz||kk|Kazakh|kazakh
kbd|||Kabardian|kabardien
kha|||Khasi|khasi
khi|||Khoisan languages|khoïsan, langues
khm||km|Central Khmer|khmer central
kho|||Khotanese; Sakan|khotanais; sakan
kik||ki|Kikuyu; Gikuyu|kikuyu
kin||rw|Kinyarwanda|rwanda
kir||ky|Kirghiz; Kyrgyz|kirghiz
kmb|||Kimbundu|kimbundu
kok|||Konkani|konkani
kom||kv|Komi|kom
kon||kg|Kongo|kongo
kor||ko|Korean|coréen
kos|||Kosraean|kosrae
kpe|||Kpelle|kpellé
krc|||Karachay-Balkar|karatchai balkar
krl|||Karelian|carélien
kro|||Kru languages|krou, langues
kru|||Kurukh|kurukh
kua||kj|Kuanyama; Kwanyama|kuanyama; kwanyama
kum|||Kumyk|koumyk
kur||ku|Kurdish|kurde
kut|||Kutenai|kutenai
lad|||Ladino|judéo-espagnol
lah|||Lahnda|lahnda
lam|||Lamba|lamba
lao||lo|Lao|lao
lat||la|Latin|latin
lav||lv|Latvian|letton
lez|||Lezghian|lezghien
lim||li|Limburgan; Limburger; Limburgish|limbourgeois
lin||ln|Lingala|lingala
lit||lt|Lithuanian|lituanien
lol|||Mongo|mongo
loz|||Lozi|lozi
ltz||lb|Luxembourgish; Letzeburgesch|luxembourgeois
lua|||Luba-Lulua|luba-lulua
lub||lu|Luba-Katanga|luba-katanga
lug||lg|Ganda|ganda
lui|||Luiseno|luiseno
lun|||Lunda|lunda
luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie)
lus|||Lushai|lushai
mac|mkd|mk|Macedonian|macédonien
mad|||Madurese|madourais
mag|||Magahi|magahi
mah||mh|Marshallese|marshall
mai|||Maithili|maithili
mak|||Makasar|makassar
mal||ml|Malayalam|malayalam
man|||Mandingo|mandingue
mao|mri|mi|Maori|maori
map|||Austronesian languages|austronésiennes, langues
mar||mr|Marathi|marathe
mas|||Masai|massaï
may|msa|ms|Malay|malais
mdf|||Moksha|moksa
mdr|||Mandar|mandar
men|||Mende|mendé
mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200)
mic|||Mi'kmaq; Micmac|mi'kmaq; micmac
min|||Minangkabau|minangkabau
mis|||Uncoded languages|langues non codées
mkh|||Mon-Khmer languages|môn-khmer, langues
mlg||mg|Malagasy|malgache
mlt||mt|Maltese|maltais
mnc|||Manchu|mandchou
mni|||Manipuri|manipuri
mno|||Manobo languages|manobo, langues
moh|||Mohawk|mohawk
mon||mn|Mongolian|mongol
mos|||Mossi|moré
mul|||Multiple languages|multilingue
mun|||Munda languages|mounda, langues
mus|||Creek|muskogee
mwl|||Mirandese|mirandais
mwr|||Marwari|marvari
myn|||Mayan languages|maya, langues
myv|||Erzya|erza
nah|||Nahuatl languages|nahuatl, langues
nai|||North American Indian languages|nord-amérindiennes, langues
nap|||Neapolitan|napolitain
nau||na|Nauru|nauruan
nav||nv|Navajo; Navaho|navaho
nbl||nr|Ndebele, South; South Ndebele|ndébélé du Sud
nde||nd|Ndebele, North; North Ndebele|ndébélé du Nord
ndo||ng|Ndonga|ndonga
nds|||Low German; Low Saxon; German, Low; Saxon, Low|bas allemand; bas saxon; allemand, bas; saxon, bas
nep||ne|Nepali|népalais
new|||Nepal Bhasa; Newari|nepal bhasa; newari
nia|||Nias|nias
nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
niu|||Niuean|niué
nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien
nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål
nog|||Nogai|nogaï; nogay
non|||Norse, Old|norrois, vieux
nor||no|Norwegian|norvégien
nqo|||N'Ko|n'ko
nso|||Pedi; Sepedi; Northern Sotho|pedi; sepedi; sotho du Nord
nub|||Nubian languages|nubiennes, langues
nwc|||Classical Newari; Old Newari; Classical Nepal Bhasa|newari classique
nya||ny|Chichewa; Chewa; Nyanja|chichewa; chewa; nyanja
nym|||Nyamwezi|nyamwezi
nyn|||Nyankole|nyankolé
nyo|||Nyoro|nyoro
nzi|||Nzima|nzema
oci||oc|Occitan (post 1500)|occitan (après 1500)
oji||oj|Ojibwa|ojibwa
ori||or|Oriya|oriya
orm||om|Oromo|galla
osa|||Osage|osage
oss||os|Ossetian; Ossetic|ossète
ota|||Turkish, Ottoman (1500-1928)|turc ottoman (1500-1928)
oto|||Otomian languages|otomi, langues
paa|||Papuan languages|papoues, langues
pag|||Pangasinan|pangasinan
pal|||Pahlavi|pahlavi
pam|||Pampanga; Kapampangan|pampangan
pan||pa|Panjabi; Punjabi|pendjabi
pap|||Papiamento|papiamento
pau|||Palauan|palau
peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.)
per|fas|fa|Persian|persan
phi|||Philippine languages|philippines, langues
phn|||Phoenician|phénicien
pli||pi|Pali|pali
pol||pl|Polish|polonais
pon|||Pohnpeian|pohnpei
por||pt|Portuguese|portugais
pra|||Prakrit languages|prâkrit, langues
pro|||Provençal, Old (to 1500); Occitan, Old (to 1500)|provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500)
pus||ps|Pushto; Pashto|pachto
qaa-qtz|||Reserved for local use|réservée à l'usage local
que||qu|Quechua|quechua
raj|||Rajasthani|rajasthani
rap|||Rapanui|rapanui
rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook
roa|||Romance languages|romanes, langues
roh||rm|Romansh|romanche
rom|||Romany|tsigane
rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave
run||rn|Rundi|rundi
rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain
rus||ru|Russian|russe
sad|||Sandawe|sandawe
sag||sg|Sango|sango
sah|||Yakut|iakoute
sai|||South American Indian languages|sud-amérindiennes, langues
sal|||Salishan languages|salishennes, langues
sam|||Samaritan Aramaic|samaritain
san||sa|Sanskrit|sanskrit
sas|||Sasak|sasak
sat|||Santali|santal
scn|||Sicilian|sicilien
sco|||Scots|écossais
sel|||Selkup|selkoupe
sem|||Semitic languages|sémitiques, langues
sga|||Irish, Old (to 900)|irlandais ancien (jusqu'à 900)
sgn|||Sign Languages|langues des signes
shn|||Shan|chan
sid|||Sidamo|sidamo
sin||si|Sinhala; Sinhalese|singhalais
sio|||Siouan languages|sioux, langues
sit|||Sino-Tibetan languages|sino-tibétaines, langues
sla|||Slavic languages|slaves, langues
slo|slk|sk|Slovak|slovaque
slv||sl|Slovenian|slovène
sma|||Southern Sami|sami du Sud
sme||se|Northern Sami|sami du Nord
smi|||Sami languages|sames, langues
smj|||Lule Sami|sami de Lule
smn|||Inari Sami|sami d'Inari
smo||sm|Samoan|samoan
sms|||Skolt Sami|sami skolt
sna||sn|Shona|shona
snd||sd|Sindhi|sindhi
snk|||Soninke|soninké
sog|||Sogdian|sogdien
som||so|Somali|somali
son|||Songhai languages|songhai, langues
sot||st|Sotho, Southern|sotho du Sud
spa||es|Spanish; Castilian|espagnol; castillan
srd||sc|Sardinian|sarde
srn|||Sranan Tongo|sranan tongo
srp||sr|Serbian|serbe
srr|||Serer|sérère
ssa|||Nilo-Saharan languages|nilo-sahariennes, langues
ssw||ss|Swati|swati
suk|||Sukuma|sukuma
sun||su|Sundanese|soundanais
sus|||Susu|soussou
sux|||Sumerian|sumérien
swa||sw|Swahili|swahili
swe||sv|Swedish|suédois
syc|||Classical Syriac|syriaque classique
syr|||Syriac|syriaque
tah||ty|Tahitian|tahitien
tai|||Tai languages|tai, langues
tam||ta|Tamil|tamoul
tat||tt|Tatar|tatar
tel||te|Telugu|télougou
tem|||Timne|temne
ter|||Tereno|tereno
tet|||Tetum|tetum
tgk||tg|Tajik|tadjik
tgl||tl|Tagalog|tagalog
tha||th|Thai|thaï
tib|bod|bo|Tibetan|tibétain
tig|||Tigre|tigré
tir||ti|Tigrinya|tigrigna
tiv|||Tiv|tiv
tkl|||Tokelau|tokelau
tlh|||Klingon; tlhIngan-Hol|klingon
tli|||Tlingit|tlingit
tmh|||Tamashek|tamacheq
tog|||Tonga (Nyasa)|tonga (Nyasa)
ton||to|Tonga (Tonga Islands)|tongan (Îles Tonga)
tpi|||Tok Pisin|tok pisin
tsi|||Tsimshian|tsimshian
tsn||tn|Tswana|tswana
tso||ts|Tsonga|tsonga
tuk||tk|Turkmen|turkmène
tum|||Tumbuka|tumbuka
tup|||Tupi languages|tupi, langues
tur||tr|Turkish|turc
tut|||Altaic languages|altaïques, langues
tvl|||Tuvalu|tuvalu
twi||tw|Twi|twi
tyv|||Tuvinian|touva
udm|||Udmurt|oudmourte
uga|||Ugaritic|ougaritique
uig||ug|Uighur; Uyghur|ouïgour
ukr||uk|Ukrainian|ukrainien
umb|||Umbundu|umbundu
und|||Undetermined|indéterminée
urd||ur|Urdu|ourdou
uzb||uz|Uzbek|ouszbek
vai|||Vai|vaï
ven||ve|Venda|venda
vie||vi|Vietnamese|vietnamien
vol||vo|Volapük|volapük
vot|||Votic|vote
wak|||Wakashan languages|wakashanes, langues
wal|||Wolaitta; Wolaytta|wolaitta; wolaytta
war|||Waray|waray
was|||Washo|washo
wel|cym|cy|Welsh|gallois
wen|||Sorbian languages|sorabes, langues
wln||wa|Walloon|wallon
wol||wo|Wolof|wolof
xal|||Kalmyk; Oirat|kalmouk; oïrat
xho||xh|Xhosa|xhosa
yao|||Yao|yao
yap|||Yapese|yapois
yid||yi|Yiddish|yiddish
yor||yo|Yoruba|yoruba
ypk|||Yupik languages|yupik, langues
zap|||Zapotec|zapotèque
zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss
zen|||Zenaga|zenaga
zgh|||Standard Moroccan Tamazight|amazighe standard marocain
zha||za|Zhuang; Chuang|zhuang; chuang
znd|||Zande languages|zandé, langues
zul||zu|Zulu|zoulou
zun|||Zuni|zuni
zxx|||No linguistic content; Not applicable|pas de contenu linguistique; non applicable
zza|||Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki|zaza; dimili; dimli; kirdki; kirmanjki; zazaki
+1 -1
View File
@@ -72,7 +72,7 @@
protected override async Task OnParametersSetAsync()
{
_collections = await _mediator.Send(new GetAllCollections())
.Map(list => new[] { _newCollection }.Append(list).ToList());
.Map(list => new[] { _newCollection }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList());
if (_memoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
{
+3 -1
View File
@@ -1,5 +1,6 @@
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Queries
@using NaturalSort.Extension
@inject IMediator _mediator
<div @onkeydown="@OnKeyDown">
@@ -49,7 +50,8 @@
private ProgramScheduleViewModel _selectedSchedule;
protected override async Task OnParametersSetAsync() =>
_schedules = await _mediator.Send(new GetAllProgramSchedules());
_schedules = await _mediator.Send(new GetAllProgramSchedules())
.Map(list => list.OrderBy(vm => vm.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase)).ToList());
private string FormatText() => $"Select the schedule to add the {EntityType} {EntityName}";
+3 -1
View File
@@ -1,4 +1,5 @@
@using ErsatzTV.Core.Search
@using ErsatzTV.Extensions
@using Microsoft.AspNetCore.WebUtilities
<div class="letter-bar-container">
<div class="letter-bar">
@@ -48,7 +49,8 @@
var uri = $"{BaseUri}/page/{PageMap.PageMap[letter]}";
if (!string.IsNullOrWhiteSpace(Query))
{
uri = QueryHelpers.AddQueryString(uri, "query", Query);
(string key, string value) = Query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri + $"#letter-{letter}";
}
+3 -15
View File
@@ -1,7 +1,5 @@
@using System.Reflection
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using System.Web
@using ErsatzTV.Extensions
@inherits LayoutComponentBase
@inject NavigationManager _navigationManager
@@ -107,22 +105,12 @@
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = string.Empty;
}
_query = _navigationManager.Uri.GetSearchQuery();
}
private void PerformSearch()
{
string query = HttpUtility.UrlEncode(_query);
_navigationManager.NavigateTo($"/search?query={query}", true);
_navigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
StateHasChanged();
}
+12 -3
View File
@@ -7,6 +7,7 @@ using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Logs.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.FFmpeg;
@@ -144,14 +145,21 @@ namespace ErsatzTV
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure");
}));
services.AddTransient<IDbConnection>(_ => new SqliteConnection(connectionString));
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new GuidHandler());
SqlMapper.AddTypeHandler(new TimeSpanHandler());
var logConnectionString = $"Data Source={FileSystemLayout.LogDatabasePath}";
services.AddDbContext<LogContext>(
options => options.UseSqlite($"Data Source={FileSystemLayout.LogDatabasePath}"));
options => options.UseSqlite(logConnectionString),
ServiceLifetime.Scoped,
ServiceLifetime.Singleton);
services.AddDbContextFactory<LogContext>(
options => options.UseSqlite(logConnectionString));
services.AddMediatR(typeof(GetAllChannels).Assembly);
@@ -196,7 +204,6 @@ namespace ErsatzTV
services.AddScoped<IMediaItemRepository, MediaItemRepository>();
services.AddScoped<IMediaCollectionRepository, MediaCollectionRepository>();
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
services.AddScoped<ILogRepository, LogRepository>();
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
@@ -244,6 +251,8 @@ namespace ErsatzTV
services.AddScoped<IEmbySecretStore, EmbySecretStore>();
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();
services.AddHostedService<CacheCleanerService>();
@@ -13,7 +13,7 @@ namespace ErsatzTV.Validators
() => RuleFor(i => i.StartTime).NotNull());
When(
i => i.PlayoutMode == PlayoutMode.Multiple,
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThan(0));
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThanOrEqualTo(0));
When(
i => i.PlayoutMode == PlayoutMode.Duration,
() => RuleFor(i => i.PlayoutDuration).NotNull());
+4 -1
View File
@@ -132,4 +132,7 @@
.release-notes h3 { margin-top: 20px; }
.mud-table-container { overflow-x: unset; }
.mud-table-container { overflow-x: unset; }
#blazor-error-ui { color: black; }
#blazor-error-ui a { color: unset; }