Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27fefa1b38 | ||
|
|
fc3175591e | ||
|
|
3363d2c9d7 | ||
|
|
1d5217fa84 | ||
|
|
904cdb8780 | ||
|
|
85fee64565 | ||
|
|
13cfb9728f | ||
|
|
60b82876ea | ||
|
|
a99249c375 | ||
|
|
36e6ef4c18 | ||
|
|
21e53532c1 | ||
|
|
a864d53327 | ||
|
|
e6446f9983 | ||
|
|
ad40213f90 | ||
|
|
45c6d20fd0 | ||
|
|
5439db89a7 | ||
|
|
a39231bb5a | ||
|
|
4c8584b517 | ||
|
|
ca8bcacbd3 | ||
|
|
f27286d1dd | ||
|
|
23870b75f7 | ||
|
|
7f5a91c643 | ||
|
|
f1f50e883c | ||
|
|
7506f49f5b | ||
|
|
944f1e4307 | ||
|
|
f7de9ac5ea | ||
|
|
1eb51ad2f4 | ||
|
|
c3e0aaf0b7 | ||
|
|
b9912b47df | ||
|
|
55fb2624e7 | ||
|
|
8ced20dc39 | ||
|
|
e718cb0faf | ||
|
|
e218ff9a6d | ||
|
|
c2a49cbaea | ||
|
|
17e74f7314 | ||
|
|
2032bb4777 | ||
|
|
7877ec641e | ||
|
|
767a9779bb | ||
|
|
bb9127e546 | ||
|
|
c932577cb8 | ||
|
|
ad2685fb2e | ||
|
|
96bc2c28f2 | ||
|
|
a076b3eb30 | ||
|
|
fc360602ad | ||
|
|
d8b4d00a73 | ||
|
|
0638ac8a5e | ||
|
|
f1f09bd4cb | ||
|
|
f6680f29e7 | ||
|
|
1c0413452b | ||
|
|
77308a9ac5 | ||
|
|
3ea8193bb3 | ||
|
|
8ad8680027 | ||
|
|
640044814c | ||
|
|
18b5313a53 |
@@ -49,7 +49,7 @@ jobs:
|
|||||||
tag=$(git describe --tags --abbrev=0)
|
tag=$(git describe --tags --abbrev=0)
|
||||||
tag2="${tag:1}"
|
tag2="${tag:1}"
|
||||||
short=$(git rev-parse --short HEAD)
|
short=$(git rev-parse --short HEAD)
|
||||||
final="${tag2/prealpha/$short}"
|
final="${tag2/alpha/$short}"
|
||||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx Base
|
- name: Set up Docker Buildx Base
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# For most projects, this workflow file will not need changing; you simply need
|
|
||||||
# to commit it to your repository.
|
|
||||||
#
|
|
||||||
# You may wish to alter this file to override the set of languages analyzed,
|
|
||||||
# or to provide custom queries or build logic.
|
|
||||||
#
|
|
||||||
# ******** NOTE ********
|
|
||||||
# We have attempted to detect the languages in your repository. Please check
|
|
||||||
# the `language` matrix defined below to confirm you have the correct set of
|
|
||||||
# supported CodeQL languages.
|
|
||||||
#
|
|
||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: [ main ]
|
|
||||||
schedule:
|
|
||||||
- cron: '30 3 * * 0'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [ 'csharp' ]
|
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
|
||||||
# Learn more:
|
|
||||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v1
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
|
||||||
# By default, queries listed here will override any specified in a config file.
|
|
||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
|
||||||
|
|
||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v1
|
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
|
||||||
# 📚 https://git.io/JvXDl
|
|
||||||
|
|
||||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
|
||||||
# and modify them (or add more) to build your code if your project
|
|
||||||
# uses a compiled language
|
|
||||||
|
|
||||||
#- run: |
|
|
||||||
# make bootstrap
|
|
||||||
# make release
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
tag=$(git describe --tags --abbrev=0)
|
tag=$(git describe --tags --abbrev=0)
|
||||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||||
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
|
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx Base
|
- name: Set up Docker Buildx Base
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|||||||
+107
-1
@@ -5,6 +5,104 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.0.62-alpha] - 2021-10-05
|
||||||
|
### Added
|
||||||
|
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Include Specials/Season 0 `episode-num` entry in XMLTV
|
||||||
|
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
|
||||||
|
- Update Plex movie and episode paths when they are changed within Plex
|
||||||
|
- Always use `libx264` software encoder for error messages
|
||||||
|
|
||||||
|
## [0.0.61-alpha] - 2021-09-30
|
||||||
|
### Fixed
|
||||||
|
- Revert nvenc/cuda filter change from v60
|
||||||
|
|
||||||
|
## [0.0.60-alpha] - 2021-09-25
|
||||||
|
### Added
|
||||||
|
- Add Trakt list support under `Lists` > `Trakt Lists`
|
||||||
|
- Trakt lists can be added by url or by `user/list`
|
||||||
|
- To re-download a Trakt list, simply add it again (no need to delete)
|
||||||
|
- See `Logs` for unmatched item details
|
||||||
|
- Trakt lists can only be scheduled by using Smart Collections
|
||||||
|
- Add seasons to search index
|
||||||
|
- This is needed because Trakt lists can contain seasons
|
||||||
|
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed
|
||||||
|
- Properly detect ffmpeg nvenc (cuda) support in Hardware Acceleration health check
|
||||||
|
- Fix nvenc/cuda filter for some yuv420p content
|
||||||
|
|
||||||
|
## [0.0.59-alpha] - 2021-09-18
|
||||||
|
### Added
|
||||||
|
- Add `Health Checks` table to home page to identify and surface common misconfigurations
|
||||||
|
- `FFmpeg Version` checks `ffmpeg` and `ffprobe` versions
|
||||||
|
- `FFmpeg Reports` checks whether ffmpeg troubleshooting reports are enabled since they can use a lot of disk space over time
|
||||||
|
- `Hardware Acceleration` checks whether channels that transcode are using acceleration methods that ffmpeg claims to support
|
||||||
|
- `Movie Metadata` checks whether all movies have metadata (fallback metadata counts as metadata)
|
||||||
|
- `Episode Metadata` checks whether all episodes have metadata (fallback metadata counts as metadata)
|
||||||
|
- `Zero Duration` checks whether all movies and episodes have a valid (non-zero) duration
|
||||||
|
- `VAAPI Driver` checks whether a vaapi driver preference is configured when using the vaapi docker image
|
||||||
|
- Add setting to each playout to schedule an automatic daily rebuild
|
||||||
|
- This is useful if the playout uses a smart collection with `released_onthisday`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix docker vaapi support for newer Intel platforms (Gen 8+)
|
||||||
|
- This includes a new setting to force a particular vaapi driver (`iHD` or `i965`), as some Gen 8 or 9 hardware that is supported by both drivers will perform better with one or the other
|
||||||
|
- Fix scanning and indexing local movies and episodes without NFO metadata
|
||||||
|
- Fix displaying seasons for shows with no year (in metadata or in folder name)
|
||||||
|
- Fix "direct play" in MPEG-TS mode (copy audio and video stream when `Transcode` is unchecked)
|
||||||
|
|
||||||
|
## [0.0.58-alpha] - 2021-09-15
|
||||||
|
### Added
|
||||||
|
- Add `released_notinthelast` search field for relative release date queries
|
||||||
|
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
|
||||||
|
- Add `released_onthisday` search field for historical queries
|
||||||
|
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
|
||||||
|
- Add tooltip explaining `Keep Multi-Part Episodes Together`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly display watermark when no other video filters (like scaling or padding) are required
|
||||||
|
- Fix building some playouts in timezones with positive offsets (like UTC+2)
|
||||||
|
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
|
||||||
|
- You may need to rebuild playouts to see this fixed behavior more quickly
|
||||||
|
|
||||||
|
## [0.0.57-alpha] - 2021-09-10
|
||||||
|
### Added
|
||||||
|
- Add `released_inthelast` search field for relative release date queries
|
||||||
|
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
|
||||||
|
- Allow adding smart collections to multi collections
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix loading artwork in Kodi
|
||||||
|
- Use fake image extension (`.jpg`) for artwork in M3U and XMLTV since Kodi detects MIME type from URL
|
||||||
|
- Enable HEAD requests for IPTV image paths since Kodi requires those
|
||||||
|
|
||||||
|
## [0.0.56-alpha] - 2021-09-10
|
||||||
|
### Added
|
||||||
|
- Add Smart Collections
|
||||||
|
- Smart Collections use search queries and can be created from the search result page
|
||||||
|
- Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items
|
||||||
|
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||||
|
- Allow `Shuffle In Order` with Collections and Smart Collections
|
||||||
|
- Episodes will be grouped by show, and music videos will be grouped by artist
|
||||||
|
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
|
||||||
|
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Generate XMLTV that validates successfully
|
||||||
|
- Properly order elements
|
||||||
|
- Omit channels with no programmes
|
||||||
|
- Properly identify channels using the format number.etv like `15.etv`
|
||||||
|
- Fix building playouts when multi-part episode grouping is enabled and episodes are missing metadata
|
||||||
|
- Fix incorrect total items count in `Multi Collections` table
|
||||||
|
|
||||||
|
## [0.0.55-alpha] - 2021-09-03
|
||||||
|
### Fixed
|
||||||
|
- Fix all local library scanners to ignore dot underscore files (`._`)
|
||||||
|
|
||||||
## [0.0.54-alpha] - 2021-08-21
|
## [0.0.54-alpha] - 2021-08-21
|
||||||
### Added
|
### Added
|
||||||
- Add `Shuffle In Order` playback order for multi-collections.
|
- Add `Shuffle In Order` playback order for multi-collections.
|
||||||
@@ -546,7 +644,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- Initial release to facilitate testing outside of Docker.
|
- Initial release to facilitate testing outside of Docker.
|
||||||
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...HEAD
|
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...HEAD
|
||||||
|
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
|
||||||
|
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
|
||||||
|
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
|
||||||
|
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
|
||||||
|
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
|
||||||
|
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
|
||||||
|
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
|
||||||
|
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
|
||||||
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
|
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
|
||||||
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
|
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
|
||||||
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
|
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
|||||||
|
|
||||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||||
{
|
{
|
||||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -37,17 +37,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
|||||||
p.Transcode = update.Transcode;
|
p.Transcode = update.Transcode;
|
||||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||||
p.ResolutionId = update.ResolutionId;
|
p.ResolutionId = update.ResolutionId;
|
||||||
p.NormalizeVideo = update.NormalizeVideo;
|
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
|
||||||
p.VideoCodec = update.VideoCodec;
|
p.VideoCodec = update.VideoCodec;
|
||||||
p.VideoBitrate = update.VideoBitrate;
|
p.VideoBitrate = update.VideoBitrate;
|
||||||
p.VideoBufferSize = update.VideoBufferSize;
|
p.VideoBufferSize = update.VideoBufferSize;
|
||||||
p.AudioCodec = update.AudioCodec;
|
p.AudioCodec = update.AudioCodec;
|
||||||
p.AudioBitrate = update.AudioBitrate;
|
p.AudioBitrate = update.AudioBitrate;
|
||||||
p.AudioBufferSize = update.AudioBufferSize;
|
p.AudioBufferSize = update.AudioBufferSize;
|
||||||
p.NormalizeLoudness = update.NormalizeLoudness;
|
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
|
||||||
p.AudioChannels = update.AudioChannels;
|
p.AudioChannels = update.AudioChannels;
|
||||||
p.AudioSampleRate = update.AudioSampleRate;
|
p.AudioSampleRate = update.AudioSampleRate;
|
||||||
p.NormalizeAudio = update.NormalizeAudio;
|
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
return new UpdateFFmpegProfileResult(p.Id);
|
return new UpdateFFmpegProfileResult(p.Id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
|||||||
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _configElementRepository.Upsert(
|
||||||
|
ConfigElementKey.FFmpegVaapiDriver,
|
||||||
|
(int)request.Settings.VaapiDriver);
|
||||||
|
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace ErsatzTV.Application.FFmpegProfiles
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.FFmpegProfiles
|
||||||
{
|
{
|
||||||
public class FFmpegSettingsViewModel
|
public class FFmpegSettingsViewModel
|
||||||
{
|
{
|
||||||
@@ -8,5 +10,6 @@
|
|||||||
public string PreferredLanguageCode { get; set; }
|
public string PreferredLanguageCode { get; set; }
|
||||||
public bool SaveReports { get; set; }
|
public bool SaveReports { get; set; }
|
||||||
public int? GlobalWatermarkId { get; set; }
|
public int? GlobalWatermarkId { get; set; }
|
||||||
|
public VaapiDriver VaapiDriver { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.FFmpeg;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -28,6 +29,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
|||||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||||
Option<int> watermark =
|
Option<int> watermark =
|
||||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
|
||||||
|
Option<int> vaapiDriver =
|
||||||
|
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
|
||||||
|
|
||||||
var result = new FFmpegSettingsViewModel
|
var result = new FFmpegSettingsViewModel
|
||||||
{
|
{
|
||||||
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
|||||||
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
|
||||||
SaveReports = await saveReports.IfNoneAsync(false),
|
SaveReports = await saveReports.IfNoneAsync(false),
|
||||||
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
|
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
|
||||||
|
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (int watermarkId in watermark)
|
foreach (int watermarkId in watermark)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ErsatzTV.Core.Health;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Health.Queries
|
||||||
|
{
|
||||||
|
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core.Health;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Health.Queries
|
||||||
|
{
|
||||||
|
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
|
||||||
|
{
|
||||||
|
private readonly IHealthCheckService _healthCheckService;
|
||||||
|
|
||||||
|
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
|
||||||
|
_healthCheckService = healthCheckService;
|
||||||
|
|
||||||
|
public async Task<List<HealthCheckResult>> Handle(
|
||||||
|
GetAllHealthCheckResults request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
|
||||||
|
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
|||||||
|
|
||||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||||
{
|
{
|
||||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Emby;
|
using ErsatzTV.Core.Emby;
|
||||||
using ErsatzTV.Core.Jellyfin;
|
using ErsatzTV.Core.Jellyfin;
|
||||||
@@ -36,6 +37,26 @@ namespace ErsatzTV.Application.MediaCards
|
|||||||
.IfNone(string.Empty),
|
.IfNone(string.Empty),
|
||||||
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
|
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
|
||||||
|
|
||||||
|
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||||
|
SeasonMetadata seasonMetadata,
|
||||||
|
Option<JellyfinMediaSource> maybeJellyfin,
|
||||||
|
Option<EmbyMediaSource> maybeEmby)
|
||||||
|
{
|
||||||
|
string showTitle = seasonMetadata.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||||
|
m => m.Title ?? string.Empty,
|
||||||
|
() => string.Empty);
|
||||||
|
|
||||||
|
return new TelevisionSeasonCardViewModel(
|
||||||
|
showTitle,
|
||||||
|
seasonMetadata.SeasonId,
|
||||||
|
seasonMetadata.Season.SeasonNumber,
|
||||||
|
showTitle,
|
||||||
|
GetSeasonName(seasonMetadata.Season.SeasonNumber),
|
||||||
|
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
|
||||||
|
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
|
||||||
|
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
|
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
|
||||||
EpisodeMetadata episodeMetadata,
|
EpisodeMetadata episodeMetadata,
|
||||||
Option<JellyfinMediaSource> maybeJellyfin,
|
Option<JellyfinMediaSource> maybeJellyfin,
|
||||||
@@ -43,7 +64,7 @@ namespace ErsatzTV.Application.MediaCards
|
|||||||
bool isSearchResult) =>
|
bool isSearchResult) =>
|
||||||
new(
|
new(
|
||||||
episodeMetadata.EpisodeId,
|
episodeMetadata.EpisodeId,
|
||||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
|
||||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||||
m => m.Title ?? string.Empty,
|
m => m.Title ?? string.Empty,
|
||||||
() => string.Empty),
|
() => string.Empty),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
|||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards.Queries
|
namespace ErsatzTV.Application.MediaCards.Queries
|
||||||
{
|
{
|
||||||
@@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
|||||||
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
|
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
|
||||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||||
|
|
||||||
return new TelevisionSeasonCardResultsViewModel(count, results);
|
return new TelevisionSeasonCardResultsViewModel(count, results, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using ErsatzTV.Core.Search;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards
|
namespace ErsatzTV.Application.MediaCards
|
||||||
{
|
{
|
||||||
public record TelevisionSeasonCardResultsViewModel(int Count, List<TelevisionSeasonCardViewModel> Cards);
|
public record TelevisionSeasonCardResultsViewModel(
|
||||||
|
int Count,
|
||||||
|
List<TelevisionSeasonCardViewModel> Cards,
|
||||||
|
Option<SearchPageMap> PageMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
int CollectionId,
|
int CollectionId,
|
||||||
List<int> MovieIds,
|
List<int> MovieIds,
|
||||||
List<int> ShowIds,
|
List<int> ShowIds,
|
||||||
|
List<int> SeasonIds,
|
||||||
List<int> EpisodeIds,
|
List<int> EpisodeIds,
|
||||||
List<int> ArtistIds,
|
List<int> ArtistIds,
|
||||||
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
{
|
{
|
||||||
var allItems = request.MovieIds
|
var allItems = request.MovieIds
|
||||||
.Append(request.ShowIds)
|
.Append(request.ShowIds)
|
||||||
|
.Append(request.SeasonIds)
|
||||||
.Append(request.EpisodeIds)
|
.Append(request.EpisodeIds)
|
||||||
.Append(request.ArtistIds)
|
.Append(request.ArtistIds)
|
||||||
.Append(request.MusicVideoIds)
|
.Append(request.MusicVideoIds)
|
||||||
@@ -77,12 +78,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) =>
|
private async Task<Validation<BaseError, Collection>> Validate(
|
||||||
|
TvContext dbContext,
|
||||||
|
AddItemsToCollection request) =>
|
||||||
(await CollectionMustExist(dbContext, request),
|
(await CollectionMustExist(dbContext, request),
|
||||||
await ValidateMovies(request),
|
await ValidateMovies(request),
|
||||||
await ValidateShows(request),
|
await ValidateShows(request),
|
||||||
|
await ValidateSeasons(request),
|
||||||
await ValidateEpisodes(request))
|
await ValidateEpisodes(request))
|
||||||
.Apply((collection, _, _, _) => collection);
|
.Apply((collection, _, _, _, _) => collection);
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
@@ -106,6 +110,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
.MapT(_ => Unit.Default)
|
.MapT(_ => Unit.Default)
|
||||||
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
|
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
|
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToCollection request) =>
|
||||||
|
_televisionRepository.AllSeasonsExist(request.SeasonIds)
|
||||||
|
.Map(Optional)
|
||||||
|
.Filter(v => v == true)
|
||||||
|
.MapT(_ => Unit.Default)
|
||||||
|
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
|
||||||
|
|
||||||
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
|
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
|
||||||
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
|
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
|
||||||
.Map(Optional)
|
.Map(Optional)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Unit = LanguageExt.Unit;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record AddTraktList(string TraktListUrl) : IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Interfaces.Trakt;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Unit = LanguageExt.Unit;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class AddTraktListHandler : TraktCommandBase, IRequestHandler<AddTraktList, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly IEntityLocker _entityLocker;
|
||||||
|
|
||||||
|
public AddTraktListHandler(
|
||||||
|
ITraktApiClient traktApiClient,
|
||||||
|
ISearchRepository searchRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILogger<AddTraktListHandler> logger,
|
||||||
|
IEntityLocker entityLocker)
|
||||||
|
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_entityLocker = entityLocker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(AddTraktList request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Validation<BaseError, Parameters> validation = ValidateUrl(request);
|
||||||
|
return await validation.Match(
|
||||||
|
DoAdd,
|
||||||
|
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_entityLocker.UnlockTrakt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Validation<BaseError, Parameters> ValidateUrl(AddTraktList request)
|
||||||
|
{
|
||||||
|
const string PATTERN = @"(?:https:\/\/trakt\.tv\/users\/)?([\w\-_]+)\/(?:lists\/)?([\w\-_]+)";
|
||||||
|
Match match = Regex.Match(request.TraktListUrl, PATTERN);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
string user = match.Groups[1].Value;
|
||||||
|
string list = match.Groups[2].Value;
|
||||||
|
return new Parameters(user, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseError.New("Invalid Trakt list url");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, Unit>> DoAdd(Parameters parameters)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
|
return await TraktApiClient.GetUserList(parameters.User, parameters.List)
|
||||||
|
.BindT(list => SaveList(dbContext, list))
|
||||||
|
.BindT(list => SaveListItems(dbContext, list))
|
||||||
|
.BindT(list => MatchListItems(dbContext, list))
|
||||||
|
.MapT(_ => Unit.Default);
|
||||||
|
|
||||||
|
// match list items (and update in search index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Parameters(string User, string List);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using MediatR;
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
{
|
{
|
||||||
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
public record CreateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
||||||
|
|
||||||
public record CreateMultiCollection
|
public record CreateMultiCollection
|
||||||
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
.Query()
|
.Query()
|
||||||
.Include(i => i.Collection)
|
.Include(i => i.Collection)
|
||||||
.LoadAsync();
|
.LoadAsync();
|
||||||
|
await dbContext.Entry(multiCollection)
|
||||||
|
.Collection(c => c.MultiCollectionSmartItems)
|
||||||
|
.Query()
|
||||||
|
.Include(i => i.SmartCollection)
|
||||||
|
.LoadAsync();
|
||||||
return ProjectToViewModel(multiCollection);
|
return ProjectToViewModel(multiCollection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,12 +56,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
name => new MultiCollection
|
name => new MultiCollection
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
|
MultiCollectionItems = request.Items.Bind(
|
||||||
{
|
i =>
|
||||||
CollectionId = i.CollectionId,
|
{
|
||||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
if (i.CollectionId.HasValue)
|
||||||
PlaybackOrder = i.PlaybackOrder
|
{
|
||||||
}).ToList()
|
return Some(
|
||||||
|
new MultiCollectionItem
|
||||||
|
{
|
||||||
|
CollectionId = i.CollectionId.Value,
|
||||||
|
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||||
|
PlaybackOrder = i.PlaybackOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<MultiCollectionItem>.None;
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
MultiCollectionSmartItems = request.Items.Bind(
|
||||||
|
i =>
|
||||||
|
{
|
||||||
|
if (i.SmartCollectionId.HasValue)
|
||||||
|
{
|
||||||
|
return Some(
|
||||||
|
new MultiCollectionSmartItem
|
||||||
|
{
|
||||||
|
SmartCollectionId = i.SmartCollectionId.Value,
|
||||||
|
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||||
|
PlaybackOrder = i.PlaybackOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<MultiCollectionSmartItem>.None;
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
});
|
});
|
||||||
|
|
||||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record CreateSmartCollection
|
||||||
|
(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class CreateSmartCollectionHandler :
|
||||||
|
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
|
||||||
|
CreateSmartCollection request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
|
||||||
|
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<SmartCollectionViewModel> PersistCollection(
|
||||||
|
TvContext dbContext,
|
||||||
|
SmartCollection smartCollection)
|
||||||
|
{
|
||||||
|
await dbContext.SmartCollections.AddAsync(smartCollection);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
return ProjectToViewModel(smartCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||||
|
TvContext dbContext,
|
||||||
|
CreateSmartCollection request) =>
|
||||||
|
ValidateName(dbContext, request).MapT(
|
||||||
|
name => new SmartCollection
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Query = request.Query
|
||||||
|
});
|
||||||
|
|
||||||
|
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||||
|
TvContext dbContext,
|
||||||
|
CreateSmartCollection createSmartCollection)
|
||||||
|
{
|
||||||
|
List<string> allNames = await dbContext.SmartCollections
|
||||||
|
.Map(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
|
||||||
|
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
|
||||||
|
|
||||||
|
var result2 = Optional(createSmartCollection.Name)
|
||||||
|
.Filter(name => !allNames.Contains(name))
|
||||||
|
.ToValidation<BaseError>("SmartCollection name must be unique");
|
||||||
|
|
||||||
|
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record DeleteSmartCollection(int SmartCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using LanguageExt;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class DeleteSmartCollectionHandler : MediatR.IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
DeleteSmartCollection request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
|
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
|
||||||
|
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
|
||||||
|
{
|
||||||
|
dbContext.SmartCollections.Remove(smartCollection);
|
||||||
|
return dbContext.SaveChangesAsync().ToUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||||
|
TvContext dbContext,
|
||||||
|
DeleteSmartCollection request) =>
|
||||||
|
dbContext.SmartCollections
|
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId)
|
||||||
|
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record DeleteTraktList(int TraktListId) : IRequest<Either<BaseError, LanguageExt.Unit>>,
|
||||||
|
IBackgroundServiceRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Interfaces.Trakt;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using LanguageExt;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class DeleteTraktListHandler : TraktCommandBase, MediatR.IRequestHandler<DeleteTraktList, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly ISearchRepository _searchRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly IEntityLocker _entityLocker;
|
||||||
|
|
||||||
|
public DeleteTraktListHandler(
|
||||||
|
ITraktApiClient traktApiClient,
|
||||||
|
ISearchRepository searchRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILogger<DeleteTraktListHandler> logger,
|
||||||
|
IEntityLocker entityLocker)
|
||||||
|
: base(traktApiClient, searchRepository, searchIndex, logger)
|
||||||
|
{
|
||||||
|
_searchRepository = searchRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_entityLocker = entityLocker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
DeleteTraktList request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
|
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||||
|
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_entityLocker.UnlockTrakt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Unit> DoDeletion(TvContext dbContext, TraktList traktList)
|
||||||
|
{
|
||||||
|
var mediaItemIds = traktList.Items.Bind(i => Optional(i.MediaItemId)).ToList();
|
||||||
|
|
||||||
|
dbContext.TraktLists.Remove(traktList);
|
||||||
|
if (await dbContext.SaveChangesAsync() > 0)
|
||||||
|
{
|
||||||
|
foreach (int mediaItemId in mediaItemIds)
|
||||||
|
{
|
||||||
|
foreach (MediaItem mediaItem in await _searchRepository.GetItemToIndex(mediaItemId))
|
||||||
|
{
|
||||||
|
await _searchIndex.UpdateItems(_searchRepository, new[] { mediaItem }.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_searchIndex.Commit();
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Unit = LanguageExt.Unit;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record MatchTraktListItems(int TraktListId, bool Unlock = true) : IRequest<Either<BaseError, Unit>>,
|
||||||
|
IBackgroundServiceRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Interfaces.Trakt;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Unit = LanguageExt.Unit;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class MatchTraktListItemsHandler : TraktCommandBase,
|
||||||
|
IRequestHandler<MatchTraktListItems, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly IEntityLocker _entityLocker;
|
||||||
|
|
||||||
|
public MatchTraktListItemsHandler(
|
||||||
|
ITraktApiClient traktApiClient,
|
||||||
|
ISearchRepository searchRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
ILogger<MatchTraktListItemsHandler> logger,
|
||||||
|
IEntityLocker entityLocker) : base(traktApiClient, searchRepository, searchIndex, logger)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_entityLocker = entityLocker;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
MatchTraktListItems request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
|
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||||
|
return await validation.Match(
|
||||||
|
async l => await MatchListItems(dbContext, l).MapT(_ => Unit.Default),
|
||||||
|
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (request.Unlock)
|
||||||
|
{
|
||||||
|
_entityLocker.UnlockTrakt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Interfaces.Trakt;
|
||||||
|
using ErsatzTV.Core.Trakt;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using LanguageExt;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public abstract class TraktCommandBase
|
||||||
|
{
|
||||||
|
private readonly ISearchRepository _searchRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
protected TraktCommandBase(
|
||||||
|
ITraktApiClient traktApiClient,
|
||||||
|
ISearchRepository searchRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
_searchRepository = searchRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
|
_logger = logger;
|
||||||
|
TraktApiClient = traktApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ITraktApiClient TraktApiClient { get; }
|
||||||
|
|
||||||
|
protected static Task<Validation<BaseError, TraktList>>
|
||||||
|
TraktListMustExist(TvContext dbContext, int traktListId) =>
|
||||||
|
dbContext.TraktLists
|
||||||
|
.Include(l => l.Items)
|
||||||
|
.ThenInclude(i => i.Guids)
|
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == traktListId)
|
||||||
|
.Map(o => o.ToValidation<BaseError>($"TraktList {traktListId} does not exist."));
|
||||||
|
|
||||||
|
protected async Task<Either<BaseError, TraktList>> SaveList(TvContext dbContext, TraktList list)
|
||||||
|
{
|
||||||
|
Option<TraktList> maybeExisting = await dbContext.TraktLists
|
||||||
|
.Include(l => l.Items)
|
||||||
|
.ThenInclude(i => i.Guids)
|
||||||
|
.SelectOneAsync(tl => tl.Id, tl => tl.User == list.User && tl.List == list.List);
|
||||||
|
|
||||||
|
return await maybeExisting.Match(
|
||||||
|
async existing =>
|
||||||
|
{
|
||||||
|
existing.Name = list.Name;
|
||||||
|
existing.Description = list.Description;
|
||||||
|
existing.ItemCount = list.ItemCount;
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
},
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await dbContext.TraktLists.AddAsync(list);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<Either<BaseError, TraktList>> SaveListItems(TvContext dbContext, TraktList list)
|
||||||
|
{
|
||||||
|
Either<BaseError, List<TraktListItemWithGuids>> maybeItems =
|
||||||
|
await TraktApiClient.GetUserListItems(list.User, list.List);
|
||||||
|
|
||||||
|
return await maybeItems.Match<Task<Either<BaseError, TraktList>>>(
|
||||||
|
async items =>
|
||||||
|
{
|
||||||
|
var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||||
|
var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList();
|
||||||
|
var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList();
|
||||||
|
|
||||||
|
list.Items.RemoveAll(toRemove.Contains);
|
||||||
|
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
|
||||||
|
|
||||||
|
foreach (TraktListItem existing in toUpdate)
|
||||||
|
{
|
||||||
|
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
|
||||||
|
foreach (TraktListItem incoming in maybeIncoming)
|
||||||
|
{
|
||||||
|
existing.Kind = incoming.Kind;
|
||||||
|
existing.Rank = incoming.Rank;
|
||||||
|
existing.Title = incoming.Title;
|
||||||
|
existing.Year = incoming.Year;
|
||||||
|
existing.Season = incoming.Season;
|
||||||
|
existing.Episode = incoming.Episode;
|
||||||
|
existing.Guids.Clear();
|
||||||
|
existing.Guids.AddRange(incoming.Guids);
|
||||||
|
existing.MediaItemId = null;
|
||||||
|
existing.MediaItem = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
error => Task.FromResult(Left<BaseError, TraktList>(error)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<Either<BaseError, TraktList>> MatchListItems(TvContext dbContext, TraktList list)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ids = new System.Collections.Generic.HashSet<int>();
|
||||||
|
|
||||||
|
foreach (TraktListItem item in list.Items
|
||||||
|
.OrderBy(i => i.Title).ThenBy(i => i.Year).ThenBy(i => i.Season).ThenBy(i => i.Episode))
|
||||||
|
{
|
||||||
|
switch (item.Kind)
|
||||||
|
{
|
||||||
|
case TraktListItemKind.Movie:
|
||||||
|
Option<int> maybeMovieId = await IdentifyMovie(dbContext, item);
|
||||||
|
foreach (int movieId in maybeMovieId)
|
||||||
|
{
|
||||||
|
ids.Add(movieId);
|
||||||
|
item.MediaItemId = movieId;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case TraktListItemKind.Show:
|
||||||
|
Option<int> maybeShowId = await IdentifyShow(dbContext, item);
|
||||||
|
foreach (int showId in maybeShowId)
|
||||||
|
{
|
||||||
|
ids.Add(showId);
|
||||||
|
item.MediaItemId = showId;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case TraktListItemKind.Season:
|
||||||
|
Option<int> maybeSeasonId = await IdentifySeason(dbContext, item);
|
||||||
|
foreach (int seasonId in maybeSeasonId)
|
||||||
|
{
|
||||||
|
ids.Add(seasonId);
|
||||||
|
item.MediaItemId = seasonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Option<int> maybeEpisodeId = await IdentifyEpisode(dbContext, item);
|
||||||
|
foreach (int episodeId in maybeEpisodeId)
|
||||||
|
{
|
||||||
|
ids.Add(episodeId);
|
||||||
|
item.MediaItemId = episodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
foreach (int mediaItemId in ids)
|
||||||
|
{
|
||||||
|
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
|
||||||
|
foreach (MediaItem item in maybeItem)
|
||||||
|
{
|
||||||
|
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_searchIndex.Commit();
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error matching trakt list items");
|
||||||
|
return BaseError.New(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TraktListItem ProjectItem(TraktList list, TraktListItemWithGuids item)
|
||||||
|
{
|
||||||
|
var result = new TraktListItem
|
||||||
|
{
|
||||||
|
TraktList = list,
|
||||||
|
Kind = item.Kind,
|
||||||
|
TraktId = item.TraktId,
|
||||||
|
Rank = item.Rank,
|
||||||
|
Title = item.Title,
|
||||||
|
Year = item.Year,
|
||||||
|
Season = item.Season,
|
||||||
|
Episode = item.Episode,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.Guids = item.Guids.Map(g => new TraktListItemGuid { Guid = g, TraktListItem = result }).ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<int>> IdentifyMovie(TvContext dbContext, TraktListItem item)
|
||||||
|
{
|
||||||
|
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||||
|
|
||||||
|
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||||
|
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(mm => mm.MovieId);
|
||||||
|
|
||||||
|
foreach (int movieId in maybeMovieByGuid)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt movie {Title} by id", item.DisplayTitle);
|
||||||
|
return movieId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||||
|
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(mm => mm.MovieId);
|
||||||
|
|
||||||
|
foreach (int movieId in maybeMovieByTitleYear)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt movie {Title} by title/year", item.DisplayTitle);
|
||||||
|
return movieId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Unable to locate trakt movie {Title}", item.DisplayTitle);
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<int>> IdentifyShow(TvContext dbContext, TraktListItem item)
|
||||||
|
{
|
||||||
|
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||||
|
|
||||||
|
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||||
|
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.ShowId);
|
||||||
|
|
||||||
|
foreach (int showId in maybeShowByGuid)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt show {Title} by id", item.DisplayTitle);
|
||||||
|
return showId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||||
|
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.ShowId);
|
||||||
|
|
||||||
|
foreach (int showId in maybeShowByTitleYear)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt show {Title} by title/year", item.Title);
|
||||||
|
return showId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Unable to locate trakt show {Title}", item.DisplayTitle);
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<int>> IdentifySeason(TvContext dbContext, TraktListItem item)
|
||||||
|
{
|
||||||
|
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||||
|
|
||||||
|
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||||
|
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.SeasonId);
|
||||||
|
|
||||||
|
foreach (int seasonId in maybeSeasonByGuid)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt season {Title} by id", item.DisplayTitle);
|
||||||
|
return seasonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
|
||||||
|
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||||
|
.Filter(sm => sm.Season.SeasonNumber == item.Season)
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.SeasonId);
|
||||||
|
|
||||||
|
foreach (int seasonId in maybeSeasonByTitleYear)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt season {Title} by title/year/season", item.DisplayTitle);
|
||||||
|
return seasonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Unable to locate trakt season {Title}", item.DisplayTitle);
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<int>> IdentifyEpisode(TvContext dbContext, TraktListItem item)
|
||||||
|
{
|
||||||
|
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||||
|
|
||||||
|
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||||
|
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.EpisodeId);
|
||||||
|
|
||||||
|
foreach (int episodeId in maybeEpisodeByGuid)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt episode {Title} by id", item.DisplayTitle);
|
||||||
|
return episodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
|
||||||
|
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||||
|
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||||
|
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
|
||||||
|
.FirstOrDefaultAsync()
|
||||||
|
.Map(Optional)
|
||||||
|
.MapT(sm => sm.EpisodeId);
|
||||||
|
|
||||||
|
foreach (int episodeId in maybeEpisodeByTitleYear)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Located trakt episode {Title} by title/year/season/episode", item.DisplayTitle);
|
||||||
|
return episodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Unable to locate trakt episode {Title}", item.DisplayTitle);
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ using LanguageExt;
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
{
|
{
|
||||||
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
public record UpdateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
||||||
|
|
||||||
public record UpdateMultiCollection
|
public record UpdateMultiCollection
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
@@ -44,15 +45,17 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
{
|
{
|
||||||
c.Name = request.Name;
|
c.Name = request.Name;
|
||||||
|
|
||||||
// save name first so playouts don't get rebuild for a name change
|
// save name first so playouts don't get rebuilt for a name change
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
var toAdd = request.Items
|
var toAdd = request.Items
|
||||||
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
|
.Filter(i => i.CollectionId.HasValue)
|
||||||
.Map(
|
// ReSharper disable once PossibleInvalidOperationException
|
||||||
i => new MultiCollectionItem
|
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
|
||||||
|
.Map(i => new MultiCollectionItem
|
||||||
{
|
{
|
||||||
CollectionId = i.CollectionId,
|
// ReSharper disable once PossibleInvalidOperationException
|
||||||
|
CollectionId = i.CollectionId.Value,
|
||||||
MultiCollectionId = c.Id,
|
MultiCollectionId = c.Id,
|
||||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||||
PlaybackOrder = i.PlaybackOrder
|
PlaybackOrder = i.PlaybackOrder
|
||||||
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
// add new items
|
// add new items
|
||||||
c.MultiCollectionItems.AddRange(toAdd);
|
c.MultiCollectionItems.AddRange(toAdd);
|
||||||
|
|
||||||
|
var toAddSmart = request.Items
|
||||||
|
.Filter(i => i.SmartCollectionId.HasValue)
|
||||||
|
// ReSharper disable once PossibleInvalidOperationException
|
||||||
|
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
|
||||||
|
.Map(i => new MultiCollectionSmartItem
|
||||||
|
{
|
||||||
|
// ReSharper disable once PossibleInvalidOperationException
|
||||||
|
SmartCollectionId = i.SmartCollectionId.Value,
|
||||||
|
MultiCollectionId = c.Id,
|
||||||
|
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||||
|
PlaybackOrder = i.PlaybackOrder
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var toRemoveSmart = c.MultiCollectionSmartItems
|
||||||
|
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// remove items that are no longer present
|
||||||
|
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
|
||||||
|
|
||||||
|
// update existing items
|
||||||
|
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
|
||||||
|
{
|
||||||
|
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
|
||||||
|
i => i.SmartCollectionId == item.SmartCollectionId))
|
||||||
|
{
|
||||||
|
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||||
|
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add new items
|
||||||
|
c.MultiCollectionSmartItems.AddRange(toAddSmart);
|
||||||
|
|
||||||
// rebuild playouts
|
// rebuild playouts
|
||||||
if (await dbContext.SaveChangesAsync() > 0)
|
if (await dbContext.SaveChangesAsync() > 0)
|
||||||
{
|
{
|
||||||
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
|||||||
UpdateMultiCollection updateCollection) =>
|
UpdateMultiCollection updateCollection) =>
|
||||||
dbContext.MultiCollections
|
dbContext.MultiCollections
|
||||||
.Include(mc => mc.MultiCollectionItems)
|
.Include(mc => mc.MultiCollectionItems)
|
||||||
|
.Include(mc => mc.MultiCollectionSmartItems)
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
|
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
|
||||||
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
|
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Unit = LanguageExt.Unit;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public record UpdateSmartCollection(int Id, string Query) : IRequest<Either<BaseError, Unit>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Application.Playouts.Commands;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using LanguageExt;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
{
|
||||||
|
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
|
||||||
|
{
|
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||||
|
|
||||||
|
public UpdateSmartCollectionHandler(
|
||||||
|
IDbContextFactory<TvContext> dbContextFactory,
|
||||||
|
IMediaCollectionRepository mediaCollectionRepository,
|
||||||
|
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_mediaCollectionRepository = mediaCollectionRepository;
|
||||||
|
_channel = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
|
UpdateSmartCollection request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
|
||||||
|
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
|
||||||
|
{
|
||||||
|
c.Query = request.Query;
|
||||||
|
|
||||||
|
// rebuild playouts
|
||||||
|
if (await dbContext.SaveChangesAsync() > 0)
|
||||||
|
{
|
||||||
|
// rebuild all playouts that use this smart collection
|
||||||
|
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
|
||||||
|
{
|
||||||
|
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||||
|
TvContext dbContext,
|
||||||
|
UpdateSmartCollection request) => SmartCollectionMustExist(dbContext, request);
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||||
|
TvContext dbContext,
|
||||||
|
UpdateSmartCollection updateCollection) =>
|
||||||
|
dbContext.SmartCollections
|
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id)
|
||||||
|
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,20 @@ namespace ErsatzTV.Application.MediaCollections
|
|||||||
new(
|
new(
|
||||||
multiCollection.Id,
|
multiCollection.Id,
|
||||||
multiCollection.Name,
|
multiCollection.Name,
|
||||||
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
|
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
|
||||||
|
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
|
||||||
|
|
||||||
|
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
|
||||||
|
new(collection.Id, collection.Name, collection.Query);
|
||||||
|
|
||||||
|
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
|
||||||
|
new(
|
||||||
|
traktList.Id,
|
||||||
|
traktList.TraktId,
|
||||||
|
$"{traktList.User}/{traktList.List}",
|
||||||
|
traktList.Name,
|
||||||
|
traktList.ItemCount,
|
||||||
|
traktList.Items.Count(i => i.MediaItemId.HasValue));
|
||||||
|
|
||||||
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
|
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
|
||||||
new(
|
new(
|
||||||
@@ -21,5 +34,12 @@ namespace ErsatzTV.Application.MediaCollections
|
|||||||
ProjectToViewModel(multiCollectionItem.Collection),
|
ProjectToViewModel(multiCollectionItem.Collection),
|
||||||
multiCollectionItem.ScheduleAsGroup,
|
multiCollectionItem.ScheduleAsGroup,
|
||||||
multiCollectionItem.PlaybackOrder);
|
multiCollectionItem.PlaybackOrder);
|
||||||
|
|
||||||
|
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
|
||||||
|
new(
|
||||||
|
multiCollectionSmartItem.MultiCollectionId,
|
||||||
|
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
|
||||||
|
multiCollectionSmartItem.ScheduleAsGroup,
|
||||||
|
multiCollectionSmartItem.PlaybackOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
|
{
|
||||||
|
public record MultiCollectionSmartItemViewModel(
|
||||||
|
int MultiCollectionId,
|
||||||
|
SmartCollectionViewModel SmartCollection,
|
||||||
|
bool ScheduleAsGroup,
|
||||||
|
PlaybackOrder PlaybackOrder);
|
||||||
|
}
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCollections
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
{
|
{
|
||||||
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
|
public record MultiCollectionViewModel(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
List<MultiCollectionItemViewModel> Items,
|
||||||
|
List<MultiCollectionSmartItemViewModel> SmartItems);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
|
{
|
||||||
|
public record PagedSmartCollectionsViewModel(int TotalCount, List<SmartCollectionViewModel> Page);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
|
{
|
||||||
|
public record PagedTraktListsViewModel(int TotalCount, List<TraktListViewModel> Page);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public record GetAllSmartCollections : IRequest<List<SmartCollectionViewModel>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollections, List<SmartCollectionViewModel>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetAllSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<List<SmartCollectionViewModel>> Handle(
|
||||||
|
GetAllSmartCollections request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
return await dbContext.SmartCollections
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
|
|||||||
return await dbContext.MultiCollections
|
return await dbContext.MultiCollections
|
||||||
.Include(mc => mc.MultiCollectionItems)
|
.Include(mc => mc.MultiCollectionItems)
|
||||||
.ThenInclude(mc => mc.Collection)
|
.ThenInclude(mc => mc.Collection)
|
||||||
|
.Include(mc => mc.MultiCollectionSmartItems)
|
||||||
|
.ThenInclude(mc => mc.SmartCollection)
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||||
.MapT(ProjectToViewModel);
|
.MapT(ProjectToViewModel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
|
|||||||
GetPagedMultiCollections request,
|
GetPagedMultiCollections request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
|
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM MultiCollection");
|
||||||
|
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(
|
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public record GetPagedSmartCollections(int PageNum, int PageSize) : IRequest<PagedSmartCollectionsViewModel>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public class GetPagedSmartCollectionsHandler : IRequestHandler<GetPagedSmartCollections, PagedSmartCollectionsViewModel>
|
||||||
|
{
|
||||||
|
private readonly IDbConnection _dbConnection;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_dbConnection = dbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedSmartCollectionsViewModel> Handle(
|
||||||
|
GetPagedSmartCollections request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM SmartCollection");
|
||||||
|
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
List<SmartCollectionViewModel> page = await dbContext.SmartCollections.FromSqlRaw(
|
||||||
|
@"SELECT * FROM SmartCollection
|
||||||
|
ORDER BY Name
|
||||||
|
COLLATE NOCASE
|
||||||
|
LIMIT {0} OFFSET {1}",
|
||||||
|
request.PageSize,
|
||||||
|
request.PageNum * request.PageSize)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||||
|
|
||||||
|
return new PagedSmartCollectionsViewModel(count, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public record GetPagedTraktLists(int PageNum, int PageSize) : IRequest<PagedTraktListsViewModel>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||||
|
{
|
||||||
|
public class GetPagedTraktListsHandler : IRequestHandler<GetPagedTraktLists, PagedTraktListsViewModel>
|
||||||
|
{
|
||||||
|
private readonly IDbConnection _dbConnection;
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public GetPagedTraktListsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||||
|
{
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_dbConnection = dbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PagedTraktListsViewModel> Handle(
|
||||||
|
GetPagedTraktLists request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM TraktList");
|
||||||
|
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
List<TraktListViewModel> page = await dbContext.TraktLists.FromSqlRaw(
|
||||||
|
@"SELECT * FROM TraktList
|
||||||
|
ORDER BY Name
|
||||||
|
COLLATE NOCASE
|
||||||
|
LIMIT {0} OFFSET {1}",
|
||||||
|
request.PageSize,
|
||||||
|
request.PageNum * request.PageSize)
|
||||||
|
.Include(l => l.Items)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||||
|
|
||||||
|
return new PagedTraktListsViewModel(count, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
|
{
|
||||||
|
public record SmartCollectionViewModel(int Id, string Name, string Query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace ErsatzTV.Application.MediaCollections
|
||||||
|
{
|
||||||
|
public record TraktListViewModel(int Id, int TraktId, string Slug, string Name, int ItemCount, int MatchCount);
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
|||||||
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
||||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||||
|
|
||||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||||
if (forceScan || nextScan < DateTimeOffset.Now)
|
if (forceScan || nextScan < DateTimeOffset.Now)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Playouts.Commands
|
||||||
|
{
|
||||||
|
public record UpdatePlayout
|
||||||
|
(int PlayoutId, Option<TimeSpan> DailyRebuildTime) : IRequest<Either<BaseError, PlayoutNameViewModel>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Infrastructure.Data;
|
||||||
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Playouts.Commands
|
||||||
|
{
|
||||||
|
public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseError, PlayoutNameViewModel>>
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||||
|
|
||||||
|
public UpdatePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
|
||||||
|
UpdatePlayout request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
|
||||||
|
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
|
||||||
|
TvContext dbContext,
|
||||||
|
UpdatePlayout request,
|
||||||
|
Playout playout)
|
||||||
|
{
|
||||||
|
playout.DailyRebuildTime = null;
|
||||||
|
|
||||||
|
foreach (TimeSpan dailyRebuildTime in request.DailyRebuildTime)
|
||||||
|
{
|
||||||
|
playout.DailyRebuildTime = dailyRebuildTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new PlayoutNameViewModel(
|
||||||
|
playout.Id,
|
||||||
|
playout.Channel.Name,
|
||||||
|
playout.Channel.Number,
|
||||||
|
playout.ProgramSchedule.Name,
|
||||||
|
Optional(playout.DailyRebuildTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
|
||||||
|
PlayoutMustExist(dbContext, request);
|
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
|
||||||
|
TvContext dbContext,
|
||||||
|
UpdatePlayout updatePlayout) =>
|
||||||
|
dbContext.Playouts
|
||||||
|
.Include(p => p.Channel)
|
||||||
|
.Include(p => p.ProgramSchedule)
|
||||||
|
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
|
||||||
|
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
namespace ErsatzTV.Application.Playouts
|
using System;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Playouts
|
||||||
{
|
{
|
||||||
public record PlayoutNameViewModel(
|
public record PlayoutNameViewModel(
|
||||||
int PlayoutId,
|
int PlayoutId,
|
||||||
string ChannelName,
|
string ChannelName,
|
||||||
string ChannelNumber,
|
string ChannelNumber,
|
||||||
string ScheduleName);
|
string ScheduleName,
|
||||||
|
Option<TimeSpan> DailyRebuildTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Playouts.Queries
|
namespace ErsatzTV.Application.Playouts.Queries
|
||||||
{
|
{
|
||||||
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
|
|||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
return await dbContext.Playouts
|
return await dbContext.Playouts
|
||||||
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
|
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
|
||||||
.Map(p => new PlayoutNameViewModel(p.Id, p.Channel.Name, p.Channel.Number, p.ProgramSchedule.Name))
|
.Map(
|
||||||
|
p => new PlayoutNameViewModel(
|
||||||
|
p.Id,
|
||||||
|
p.Channel.Name,
|
||||||
|
p.Channel.Number,
|
||||||
|
p.ProgramSchedule.Name,
|
||||||
|
Optional(p.DailyRebuildTime)))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
|
|
||||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||||
{
|
{
|
||||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
ProgramScheduleItemCollectionType CollectionType,
|
ProgramScheduleItemCollectionType CollectionType,
|
||||||
int? CollectionId,
|
int? CollectionId,
|
||||||
int? MultiCollectionId,
|
int? MultiCollectionId,
|
||||||
|
int? SmartCollectionId,
|
||||||
int? MediaItemId,
|
int? MediaItemId,
|
||||||
PlaybackOrder PlaybackOrder,
|
PlaybackOrder PlaybackOrder,
|
||||||
int? MultipleCount,
|
int? MultipleCount,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
ProgramScheduleItemCollectionType CollectionType { get; }
|
ProgramScheduleItemCollectionType CollectionType { get; }
|
||||||
int? CollectionId { get; }
|
int? CollectionId { get; }
|
||||||
int? MultiCollectionId { get; }
|
int? MultiCollectionId { get; }
|
||||||
|
int? SmartCollectionId { get; }
|
||||||
int? MediaItemId { get; }
|
int? MediaItemId { get; }
|
||||||
PlayoutMode PlayoutMode { get; }
|
PlayoutMode PlayoutMode { get; }
|
||||||
PlaybackOrder PlaybackOrder { get; }
|
PlaybackOrder PlaybackOrder { get; }
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (item.PlaybackOrder == PlaybackOrder.ShuffleInOrder)
|
|
||||||
{
|
|
||||||
return BaseError.New("Invalid playback order: 'Shuffle In Order'");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (item.PlayoutMode)
|
switch (item.PlayoutMode)
|
||||||
{
|
{
|
||||||
@@ -112,6 +108,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
|
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ProgramScheduleItemCollectionType.SmartCollection:
|
||||||
|
if (item.SmartCollectionId is null)
|
||||||
|
{
|
||||||
|
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return BaseError.New("[CollectionType] is invalid");
|
return BaseError.New("[CollectionType] is invalid");
|
||||||
@@ -134,6 +137,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
CollectionType = item.CollectionType,
|
CollectionType = item.CollectionType,
|
||||||
CollectionId = item.CollectionId,
|
CollectionId = item.CollectionId,
|
||||||
MultiCollectionId = item.MultiCollectionId,
|
MultiCollectionId = item.MultiCollectionId,
|
||||||
|
SmartCollectionId = item.SmartCollectionId,
|
||||||
MediaItemId = item.MediaItemId,
|
MediaItemId = item.MediaItemId,
|
||||||
PlaybackOrder = item.PlaybackOrder,
|
PlaybackOrder = item.PlaybackOrder,
|
||||||
CustomTitle = item.CustomTitle
|
CustomTitle = item.CustomTitle
|
||||||
@@ -146,6 +150,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
CollectionType = item.CollectionType,
|
CollectionType = item.CollectionType,
|
||||||
CollectionId = item.CollectionId,
|
CollectionId = item.CollectionId,
|
||||||
MultiCollectionId = item.MultiCollectionId,
|
MultiCollectionId = item.MultiCollectionId,
|
||||||
|
SmartCollectionId = item.SmartCollectionId,
|
||||||
MediaItemId = item.MediaItemId,
|
MediaItemId = item.MediaItemId,
|
||||||
PlaybackOrder = item.PlaybackOrder,
|
PlaybackOrder = item.PlaybackOrder,
|
||||||
CustomTitle = item.CustomTitle
|
CustomTitle = item.CustomTitle
|
||||||
@@ -158,6 +163,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
CollectionType = item.CollectionType,
|
CollectionType = item.CollectionType,
|
||||||
CollectionId = item.CollectionId,
|
CollectionId = item.CollectionId,
|
||||||
MultiCollectionId = item.MultiCollectionId,
|
MultiCollectionId = item.MultiCollectionId,
|
||||||
|
SmartCollectionId = item.SmartCollectionId,
|
||||||
MediaItemId = item.MediaItemId,
|
MediaItemId = item.MediaItemId,
|
||||||
PlaybackOrder = item.PlaybackOrder,
|
PlaybackOrder = item.PlaybackOrder,
|
||||||
Count = item.MultipleCount.GetValueOrDefault(),
|
Count = item.MultipleCount.GetValueOrDefault(),
|
||||||
@@ -171,6 +177,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
CollectionType = item.CollectionType,
|
CollectionType = item.CollectionType,
|
||||||
CollectionId = item.CollectionId,
|
CollectionId = item.CollectionId,
|
||||||
MultiCollectionId = item.MultiCollectionId,
|
MultiCollectionId = item.MultiCollectionId,
|
||||||
|
SmartCollectionId = item.SmartCollectionId,
|
||||||
MediaItemId = item.MediaItemId,
|
MediaItemId = item.MediaItemId,
|
||||||
PlaybackOrder = item.PlaybackOrder,
|
PlaybackOrder = item.PlaybackOrder,
|
||||||
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
|
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
ProgramScheduleItemCollectionType CollectionType,
|
ProgramScheduleItemCollectionType CollectionType,
|
||||||
int? CollectionId,
|
int? CollectionId,
|
||||||
int? MultiCollectionId,
|
int? MultiCollectionId,
|
||||||
|
int? SmartCollectionId,
|
||||||
int? MediaItemId,
|
int? MediaItemId,
|
||||||
PlaybackOrder PlaybackOrder,
|
PlaybackOrder PlaybackOrder,
|
||||||
int? MultipleCount,
|
int? MultipleCount,
|
||||||
|
|||||||
+4
-2
@@ -88,7 +88,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
item.CollectionType,
|
item.CollectionType,
|
||||||
item.CollectionId,
|
item.CollectionId,
|
||||||
item.MediaItemId,
|
item.MediaItemId,
|
||||||
item.MultiCollectionId);
|
item.MultiCollectionId,
|
||||||
|
item.SmartCollectionId);
|
||||||
|
|
||||||
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
|
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
|
||||||
{
|
{
|
||||||
@@ -111,6 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
|||||||
ProgramScheduleItemCollectionType CollectionType,
|
ProgramScheduleItemCollectionType CollectionType,
|
||||||
int? CollectionId,
|
int? CollectionId,
|
||||||
int? MediaItemId,
|
int? MediaItemId,
|
||||||
int? MultiCollectionId);
|
int? MultiCollectionId,
|
||||||
|
int? SmartCollectionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
duration.MultiCollection != null
|
duration.MultiCollection != null
|
||||||
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
|
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
|
||||||
: null,
|
: null,
|
||||||
|
duration.SmartCollection != null
|
||||||
|
? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection)
|
||||||
|
: null,
|
||||||
duration.MediaItem switch
|
duration.MediaItem switch
|
||||||
{
|
{
|
||||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||||
@@ -52,6 +55,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
flood.MultiCollection != null
|
flood.MultiCollection != null
|
||||||
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
|
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
|
||||||
: null,
|
: null,
|
||||||
|
flood.SmartCollection != null
|
||||||
|
? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection)
|
||||||
|
: null,
|
||||||
flood.MediaItem switch
|
flood.MediaItem switch
|
||||||
{
|
{
|
||||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||||
@@ -74,6 +80,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
multiple.MultiCollection != null
|
multiple.MultiCollection != null
|
||||||
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
|
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
|
||||||
: null,
|
: null,
|
||||||
|
multiple.SmartCollection != null
|
||||||
|
? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection)
|
||||||
|
: null,
|
||||||
multiple.MediaItem switch
|
multiple.MediaItem switch
|
||||||
{
|
{
|
||||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||||
@@ -97,6 +106,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
one.MultiCollection != null
|
one.MultiCollection != null
|
||||||
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
|
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
|
||||||
: null,
|
: null,
|
||||||
|
one.SmartCollection != null
|
||||||
|
? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection)
|
||||||
|
: null,
|
||||||
one.MediaItem switch
|
one.MediaItem switch
|
||||||
{
|
{
|
||||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
ProgramScheduleItemCollectionType collectionType,
|
ProgramScheduleItemCollectionType collectionType,
|
||||||
MediaCollectionViewModel collection,
|
MediaCollectionViewModel collection,
|
||||||
MultiCollectionViewModel multiCollection,
|
MultiCollectionViewModel multiCollection,
|
||||||
|
SmartCollectionViewModel smartCollection,
|
||||||
NamedMediaItemViewModel mediaItem,
|
NamedMediaItemViewModel mediaItem,
|
||||||
PlaybackOrder playbackOrder,
|
PlaybackOrder playbackOrder,
|
||||||
TimeSpan playoutDuration,
|
TimeSpan playoutDuration,
|
||||||
@@ -28,6 +29,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
collectionType,
|
collectionType,
|
||||||
collection,
|
collection,
|
||||||
multiCollection,
|
multiCollection,
|
||||||
|
smartCollection,
|
||||||
mediaItem,
|
mediaItem,
|
||||||
playbackOrder,
|
playbackOrder,
|
||||||
customTitle)
|
customTitle)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
ProgramScheduleItemCollectionType collectionType,
|
ProgramScheduleItemCollectionType collectionType,
|
||||||
MediaCollectionViewModel collection,
|
MediaCollectionViewModel collection,
|
||||||
MultiCollectionViewModel multiCollection,
|
MultiCollectionViewModel multiCollection,
|
||||||
|
SmartCollectionViewModel smartCollection,
|
||||||
NamedMediaItemViewModel mediaItem,
|
NamedMediaItemViewModel mediaItem,
|
||||||
PlaybackOrder playbackOrder,
|
PlaybackOrder playbackOrder,
|
||||||
string customTitle) : base(
|
string customTitle) : base(
|
||||||
@@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
collectionType,
|
collectionType,
|
||||||
collection,
|
collection,
|
||||||
multiCollection,
|
multiCollection,
|
||||||
|
smartCollection,
|
||||||
mediaItem,
|
mediaItem,
|
||||||
playbackOrder,
|
playbackOrder,
|
||||||
customTitle)
|
customTitle)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
ProgramScheduleItemCollectionType collectionType,
|
ProgramScheduleItemCollectionType collectionType,
|
||||||
MediaCollectionViewModel collection,
|
MediaCollectionViewModel collection,
|
||||||
MultiCollectionViewModel multiCollection,
|
MultiCollectionViewModel multiCollection,
|
||||||
|
SmartCollectionViewModel smartCollection,
|
||||||
NamedMediaItemViewModel mediaItem,
|
NamedMediaItemViewModel mediaItem,
|
||||||
PlaybackOrder playbackOrder,
|
PlaybackOrder playbackOrder,
|
||||||
int count,
|
int count,
|
||||||
@@ -27,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
collectionType,
|
collectionType,
|
||||||
collection,
|
collection,
|
||||||
multiCollection,
|
multiCollection,
|
||||||
|
smartCollection,
|
||||||
mediaItem,
|
mediaItem,
|
||||||
playbackOrder,
|
playbackOrder,
|
||||||
customTitle) =>
|
customTitle) =>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
ProgramScheduleItemCollectionType collectionType,
|
ProgramScheduleItemCollectionType collectionType,
|
||||||
MediaCollectionViewModel collection,
|
MediaCollectionViewModel collection,
|
||||||
MultiCollectionViewModel multiCollection,
|
MultiCollectionViewModel multiCollection,
|
||||||
|
SmartCollectionViewModel smartCollection,
|
||||||
NamedMediaItemViewModel mediaItem,
|
NamedMediaItemViewModel mediaItem,
|
||||||
PlaybackOrder playbackOrder,
|
PlaybackOrder playbackOrder,
|
||||||
string customTitle) : base(
|
string customTitle) : base(
|
||||||
@@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
collectionType,
|
collectionType,
|
||||||
collection,
|
collection,
|
||||||
multiCollection,
|
multiCollection,
|
||||||
|
smartCollection,
|
||||||
mediaItem,
|
mediaItem,
|
||||||
playbackOrder,
|
playbackOrder,
|
||||||
customTitle)
|
customTitle)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
ProgramScheduleItemCollectionType CollectionType,
|
ProgramScheduleItemCollectionType CollectionType,
|
||||||
MediaCollectionViewModel Collection,
|
MediaCollectionViewModel Collection,
|
||||||
MultiCollectionViewModel MultiCollection,
|
MultiCollectionViewModel MultiCollection,
|
||||||
|
SmartCollectionViewModel SmartCollection,
|
||||||
NamedMediaItemViewModel MediaItem,
|
NamedMediaItemViewModel MediaItem,
|
||||||
PlaybackOrder PlaybackOrder,
|
PlaybackOrder PlaybackOrder,
|
||||||
string CustomTitle)
|
string CustomTitle)
|
||||||
@@ -29,6 +30,8 @@ namespace ErsatzTV.Application.ProgramSchedules
|
|||||||
MediaItem?.Name,
|
MediaItem?.Name,
|
||||||
ProgramScheduleItemCollectionType.MultiCollection =>
|
ProgramScheduleItemCollectionType.MultiCollection =>
|
||||||
MultiCollection?.Name,
|
MultiCollection?.Name,
|
||||||
|
ProgramScheduleItemCollectionType.SmartCollection =>
|
||||||
|
SmartCollection?.Name,
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
|
|||||||
.Filter(psi => psi.ProgramScheduleId == request.Id)
|
.Filter(psi => psi.ProgramScheduleId == request.Id)
|
||||||
.Include(i => i.Collection)
|
.Include(i => i.Collection)
|
||||||
.Include(i => i.MultiCollection)
|
.Include(i => i.MultiCollection)
|
||||||
|
.Include(i => i.SmartCollection)
|
||||||
.Include(i => i.MediaItem)
|
.Include(i => i.MediaItem)
|
||||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||||
.ThenInclude(mm => mm.Artwork)
|
.ThenInclude(mm => mm.Artwork)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Search.Queries
|
|||||||
new(
|
new(
|
||||||
await GetIds("movie", request.Query),
|
await GetIds("movie", request.Query),
|
||||||
await GetIds("show", request.Query),
|
await GetIds("show", request.Query),
|
||||||
|
await GetIds("season", request.Query),
|
||||||
await GetIds("episode", request.Query),
|
await GetIds("episode", request.Query),
|
||||||
await GetIds("artist", request.Query),
|
await GetIds("artist", request.Query),
|
||||||
await GetIds("music_video", request.Query));
|
await GetIds("music_video", request.Query));
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ErsatzTV.Application.MediaCards;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search.Queries
|
||||||
|
{
|
||||||
|
public record QuerySearchIndexSeasons
|
||||||
|
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionSeasonCardResultsViewModel>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Application.MediaCards;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Search;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search.Queries
|
||||||
|
{
|
||||||
|
public class
|
||||||
|
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||||
|
{
|
||||||
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
private readonly ITelevisionRepository _televisionRepository;
|
||||||
|
|
||||||
|
public QuerySearchIndexSeasonsHandler(
|
||||||
|
ISearchIndex searchIndex,
|
||||||
|
ITelevisionRepository televisionRepository,
|
||||||
|
IMediaSourceRepository mediaSourceRepository)
|
||||||
|
{
|
||||||
|
_searchIndex = searchIndex;
|
||||||
|
_televisionRepository = televisionRepository;
|
||||||
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
|
||||||
|
QuerySearchIndexSeasons request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SearchResult searchResult = await _searchIndex.Search(
|
||||||
|
request.Query,
|
||||||
|
(request.PageNumber - 1) * request.PageSize,
|
||||||
|
request.PageSize);
|
||||||
|
|
||||||
|
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||||
|
.Map(list => list.HeadOrNone());
|
||||||
|
|
||||||
|
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||||
|
.Map(list => list.HeadOrNone());
|
||||||
|
|
||||||
|
List<TelevisionSeasonCardViewModel> items = await _televisionRepository
|
||||||
|
.GetSeasonsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||||
|
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
|
||||||
|
|
||||||
|
return new TelevisionSeasonCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search
|
|||||||
public record SearchResultAllItemsViewModel(
|
public record SearchResultAllItemsViewModel(
|
||||||
List<int> MovieIds,
|
List<int> MovieIds,
|
||||||
List<int> ShowIds,
|
List<int> ShowIds,
|
||||||
|
List<int> SeasonIds,
|
||||||
List<int> EpisodeIds,
|
List<int> EpisodeIds,
|
||||||
List<int> ArtistIds,
|
List<int> ArtistIds,
|
||||||
List<int> MusicVideoIds);
|
List<int> MusicVideoIds);
|
||||||
|
|||||||
+6
-1
@@ -99,6 +99,10 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
watermarkId => dbContext.ChannelWatermarks
|
watermarkId => dbContext.ChannelWatermarks
|
||||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||||
|
|
||||||
|
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
|
||||||
|
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
|
||||||
|
.MapT(i => (VaapiDriver)i);
|
||||||
|
|
||||||
return Right<BaseError, Process>(
|
return Right<BaseError, Process>(
|
||||||
await _ffmpegProcessService.ForPlayoutItem(
|
await _ffmpegProcessService.ForPlayoutItem(
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
@@ -108,7 +112,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
playoutItemWithPath.Path,
|
playoutItemWithPath.Path,
|
||||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||||
now,
|
now,
|
||||||
maybeGlobalWatermark));
|
maybeGlobalWatermark,
|
||||||
|
maybeVaapiDriver));
|
||||||
},
|
},
|
||||||
async error =>
|
async error =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="FluentAssertions" Version="6.0.0" />
|
<PackageReference Include="FluentAssertions" Version="6.1.0" />
|
||||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||||
{
|
{
|
||||||
@@ -113,6 +114,161 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.BottomLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.BottomRight,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0][1:v]overlay=x=134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopRight,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
true,
|
||||||
|
100,
|
||||||
|
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
90,
|
||||||
|
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
true,
|
||||||
|
100,
|
||||||
|
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
|
||||||
|
"0:1",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||||
|
"[a]",
|
||||||
|
"[v]")]
|
||||||
|
[TestCase(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
ChannelWatermarkLocation.TopLeft,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
|
||||||
|
"[a]",
|
||||||
|
"[v]")]
|
||||||
|
public void Should_Return_Watermark(
|
||||||
|
bool alignAudio,
|
||||||
|
bool deinterlace,
|
||||||
|
bool intermittent,
|
||||||
|
ChannelWatermarkLocation location,
|
||||||
|
bool scaled,
|
||||||
|
int opacity,
|
||||||
|
string expectedVideoFilter,
|
||||||
|
string expectedAudioLabel,
|
||||||
|
string expectedVideoLabel)
|
||||||
|
{
|
||||||
|
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||||
|
.WithWatermark(
|
||||||
|
Some(
|
||||||
|
new ChannelWatermark
|
||||||
|
{
|
||||||
|
Mode = intermittent
|
||||||
|
? ChannelWatermarkMode.Intermittent
|
||||||
|
: ChannelWatermarkMode.Permanent,
|
||||||
|
DurationSeconds = intermittent ? 15 : 0,
|
||||||
|
FrequencyMinutes = intermittent ? 10 : 0,
|
||||||
|
Location = location,
|
||||||
|
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||||
|
WidthPercent = scaled ? 20 : 0,
|
||||||
|
Opacity = opacity,
|
||||||
|
HorizontalMarginPercent = 7,
|
||||||
|
VerticalMarginPercent = 5
|
||||||
|
}),
|
||||||
|
new Resolution { Width = 1920, Height = 1080 })
|
||||||
|
.WithDeinterlace(deinterlace)
|
||||||
|
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||||
|
|
||||||
|
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||||
|
|
||||||
|
result.IsSome.Should().BeTrue();
|
||||||
|
result.IfSome(
|
||||||
|
filter =>
|
||||||
|
{
|
||||||
|
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||||
|
filter.AudioLabel.Should().Be(expectedAudioLabel);
|
||||||
|
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
@@ -281,7 +437,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"h264",
|
"h264",
|
||||||
@@ -295,14 +451,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"h264",
|
"h264",
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"h264",
|
"h264",
|
||||||
@@ -316,7 +472,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
@@ -324,7 +480,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"mpeg4",
|
"mpeg4",
|
||||||
@@ -338,14 +494,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"mpeg4",
|
"mpeg4",
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
[TestCase(
|
[TestCase(
|
||||||
"mpeg4",
|
"mpeg4",
|
||||||
@@ -359,7 +515,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||||
"[v]")]
|
"[v]")]
|
||||||
public void Should_Return_VAAPI_Video_Filter(
|
public void Should_Return_VAAPI_Video_Filter(
|
||||||
string codec,
|
string codec,
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||||
};
|
};
|
||||||
@@ -283,6 +284,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1280, Height = 720 }
|
Resolution = new Resolution { Width = 1280, Height = 720 }
|
||||||
};
|
};
|
||||||
@@ -309,6 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||||
};
|
};
|
||||||
@@ -334,6 +337,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = false,
|
NormalizeVideo = false,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
Resolution = new Resolution { Width = 1920, Height = 1080 }
|
||||||
};
|
};
|
||||||
@@ -359,6 +363,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoCodec = "testCodec"
|
VideoCodec = "testCodec"
|
||||||
@@ -387,6 +392,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoCodec = "testCodec"
|
VideoCodec = "testCodec"
|
||||||
@@ -416,6 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoCodec = "testCodec"
|
VideoCodec = "testCodec"
|
||||||
@@ -444,6 +451,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoCodec = "libx264"
|
VideoCodec = "libx264"
|
||||||
@@ -473,6 +481,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = false,
|
NormalizeVideo = false,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoCodec = "libx264"
|
VideoCodec = "libx264"
|
||||||
@@ -496,11 +505,46 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
actual.VideoCodec.Should().Be("copy");
|
actual.VideoCodec.Should().Be("copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void
|
||||||
|
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
|
||||||
|
{
|
||||||
|
var ffmpegProfile = new FFmpegProfile
|
||||||
|
{
|
||||||
|
Transcode = false,
|
||||||
|
NormalizeVideo = true,
|
||||||
|
NormalizeAudio = true,
|
||||||
|
NormalizeLoudness = true,
|
||||||
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
|
VideoCodec = "libx264"
|
||||||
|
};
|
||||||
|
|
||||||
|
// not anamorphic
|
||||||
|
var version = new MediaVersion
|
||||||
|
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||||
|
|
||||||
|
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||||
|
StreamingMode.TransportStream,
|
||||||
|
ffmpegProfile,
|
||||||
|
version,
|
||||||
|
new MediaStream { Codec = "mpeg2video" },
|
||||||
|
new MediaStream(),
|
||||||
|
DateTimeOffset.Now,
|
||||||
|
DateTimeOffset.Now);
|
||||||
|
|
||||||
|
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||||
|
actual.PadToDesiredResolution.Should().BeFalse();
|
||||||
|
actual.VideoCodec.Should().Be("copy");
|
||||||
|
actual.NormalizeLoudness.Should().BeFalse();
|
||||||
|
actual.AudioCodec.Should().Be("copy");
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream()
|
public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream()
|
||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoBitrate = 2525
|
VideoBitrate = 2525
|
||||||
@@ -528,6 +572,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoBitrate = 2525
|
VideoBitrate = 2525
|
||||||
@@ -556,6 +601,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoBufferSize = 2525
|
VideoBufferSize = 2525
|
||||||
@@ -584,6 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
var ffmpegProfile = new FFmpegProfile
|
var ffmpegProfile = new FFmpegProfile
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeVideo = true,
|
NormalizeVideo = true,
|
||||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||||
VideoBufferSize = 2525
|
VideoBufferSize = 2525
|
||||||
@@ -612,6 +659,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioCodec = "aac"
|
AudioCodec = "aac"
|
||||||
};
|
};
|
||||||
@@ -658,6 +706,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioCodec = "aac"
|
AudioCodec = "aac"
|
||||||
};
|
};
|
||||||
@@ -681,6 +730,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioCodec = "aac"
|
AudioCodec = "aac"
|
||||||
};
|
};
|
||||||
@@ -704,6 +754,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioBitrate = 2424,
|
AudioBitrate = 2424,
|
||||||
AudioCodec = "ac3"
|
AudioCodec = "ac3"
|
||||||
@@ -728,6 +779,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioBufferSize = 2424,
|
AudioBufferSize = 2424,
|
||||||
AudioCodec = "ac3"
|
AudioCodec = "ac3"
|
||||||
@@ -752,6 +804,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioCodec = "ac3",
|
AudioCodec = "ac3",
|
||||||
AudioChannels = 6
|
AudioChannels = 6
|
||||||
@@ -776,6 +829,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioCodec = "ac3",
|
AudioCodec = "ac3",
|
||||||
AudioSampleRate = 48
|
AudioSampleRate = 48
|
||||||
@@ -800,6 +854,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioChannels = 6
|
AudioChannels = 6
|
||||||
};
|
};
|
||||||
@@ -823,6 +878,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioSampleRate = 48
|
AudioSampleRate = 48
|
||||||
};
|
};
|
||||||
@@ -846,6 +902,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
AudioSampleRate = 48,
|
AudioSampleRate = 48,
|
||||||
AudioCodec = "ac3"
|
AudioCodec = "ac3"
|
||||||
@@ -870,6 +927,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = true,
|
NormalizeAudio = true,
|
||||||
NormalizeLoudness = true
|
NormalizeLoudness = true
|
||||||
};
|
};
|
||||||
@@ -893,6 +951,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
|||||||
{
|
{
|
||||||
FFmpegProfile ffmpegProfile = TestProfile() with
|
FFmpegProfile ffmpegProfile = TestProfile() with
|
||||||
{
|
{
|
||||||
|
Transcode = true,
|
||||||
NormalizeAudio = false,
|
NormalizeAudio = false,
|
||||||
NormalizeLoudness = true
|
NormalizeLoudness = true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Images;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
|
using FluentAssertions;
|
||||||
|
using LanguageExt;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
[Explicit]
|
||||||
|
public class TranscodingTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
[Explicit]
|
||||||
|
public void DeleteTestVideos()
|
||||||
|
{
|
||||||
|
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Pass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestData
|
||||||
|
{
|
||||||
|
public static string[] InputCodecs =
|
||||||
|
{
|
||||||
|
"h264",
|
||||||
|
"mpeg2video",
|
||||||
|
"hevc",
|
||||||
|
"mpeg4"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string[] InputPixelFormats =
|
||||||
|
{
|
||||||
|
"yuv420p",
|
||||||
|
"yuv420p10le",
|
||||||
|
"yuvj420p",
|
||||||
|
"yuv444p",
|
||||||
|
"yuv444p10le"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Resolution[] Resolutions =
|
||||||
|
{
|
||||||
|
new() { Width = 1920, Height = 1080 },
|
||||||
|
new() { Width = 1280, Height = 720 }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string[] SoftwareCodecs =
|
||||||
|
{
|
||||||
|
"libx264",
|
||||||
|
"libx265"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static HardwareAccelerationKind[] NoAcceleration =
|
||||||
|
{
|
||||||
|
HardwareAccelerationKind.None
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string[] NvidiaCodecs =
|
||||||
|
{
|
||||||
|
"h264_nvenc",
|
||||||
|
"hevc_nvenc"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||||
|
{
|
||||||
|
HardwareAccelerationKind.Nvenc
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string[] VaapiCodecs =
|
||||||
|
{
|
||||||
|
"h264_vaapi",
|
||||||
|
"hevc_vaapi"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||||
|
{
|
||||||
|
HardwareAccelerationKind.Vaapi
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test, Combinatorial]
|
||||||
|
public async Task Transcode(
|
||||||
|
[ValueSource(typeof(TestData), nameof(TestData.InputCodecs))]
|
||||||
|
string inputCodec,
|
||||||
|
[ValueSource(typeof(TestData), nameof(TestData.InputPixelFormats))]
|
||||||
|
string inputPixelFormat,
|
||||||
|
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||||
|
Resolution profileResolution,
|
||||||
|
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||||
|
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||||
|
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||||
|
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||||
|
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||||
|
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||||
|
{
|
||||||
|
string name = GetStringSha256Hash(
|
||||||
|
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||||
|
|
||||||
|
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
var args =
|
||||||
|
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
|
||||||
|
var p1 = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "ffmpeg",
|
||||||
|
Arguments = args
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
p1.Start();
|
||||||
|
await p1.WaitForExitAsync();
|
||||||
|
p1.ExitCode.Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = new FFmpegProcessService(
|
||||||
|
new FFmpegPlaybackSettingsCalculator(),
|
||||||
|
new FakeStreamSelector(),
|
||||||
|
new Mock<IImageCache>().Object);
|
||||||
|
|
||||||
|
MediaVersion v = new MediaVersion();
|
||||||
|
|
||||||
|
var metadataRepository = new Mock<IMetadataRepository>();
|
||||||
|
metadataRepository
|
||||||
|
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||||
|
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
|
||||||
|
|
||||||
|
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||||
|
metadataRepository.Object,
|
||||||
|
new LocalFileSystem(),
|
||||||
|
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||||
|
|
||||||
|
await localStatisticsProvider.RefreshStatistics(
|
||||||
|
"ffprobe",
|
||||||
|
new Movie
|
||||||
|
{
|
||||||
|
MediaVersions = new List<MediaVersion>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
MediaFiles = new List<MediaFile>
|
||||||
|
{
|
||||||
|
new() { Path = file }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
DateTimeOffset now = DateTimeOffset.Now;
|
||||||
|
|
||||||
|
Process process = await service.ForPlayoutItem(
|
||||||
|
"ffmpeg",
|
||||||
|
false,
|
||||||
|
new Channel(Guid.NewGuid())
|
||||||
|
{
|
||||||
|
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||||
|
{
|
||||||
|
HardwareAcceleration = profileAcceleration,
|
||||||
|
VideoCodec = profileCodec
|
||||||
|
},
|
||||||
|
StreamingMode = StreamingMode.TransportStream
|
||||||
|
},
|
||||||
|
v,
|
||||||
|
file,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
None,
|
||||||
|
None);
|
||||||
|
|
||||||
|
process.StartInfo.RedirectStandardError = true;
|
||||||
|
|
||||||
|
process.Start().Should().BeTrue();
|
||||||
|
|
||||||
|
await process.StandardOutput.ReadToEndAsync();
|
||||||
|
string error = await process.StandardError.ReadToEndAsync();
|
||||||
|
await process.WaitForExitAsync();
|
||||||
|
|
||||||
|
string[] unsupportedMessages =
|
||||||
|
{
|
||||||
|
"No support for codec",
|
||||||
|
"No usable",
|
||||||
|
"Provided device doesn't support"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
|
||||||
|
{
|
||||||
|
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||||
|
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
|
||||||
|
Assert.Warn("Unsupported on this hardware");
|
||||||
|
}
|
||||||
|
else if (error.Contains("Impossible to convert between"))
|
||||||
|
{
|
||||||
|
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||||
|
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
process.ExitCode.Should().Be(0, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStringSha256Hash(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var sha = new System.Security.Cryptography.SHA256Managed();
|
||||||
|
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
|
||||||
|
byte[] hash = sha.ComputeHash(textData);
|
||||||
|
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeStreamSelector : IFFmpegStreamSelector
|
||||||
|
{
|
||||||
|
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||||
|
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||||
|
|
||||||
|
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
|
||||||
|
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
{
|
{
|
||||||
public record FakeFileEntry(string Path)
|
public record FakeFileEntry(string Path)
|
||||||
{
|
{
|
||||||
public DateTime LastWriteTime { get; set; } = DateTime.MinValue;
|
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public DateTime GetLastWriteTime(string path) =>
|
public DateTime GetLastWriteTime(string path) =>
|
||||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||||
.Map(f => f.LastWriteTime)
|
.Map(f => f.LastWriteTime)
|
||||||
.IfNone(DateTime.MinValue);
|
.IfNone(SystemTime.MinValueUtc);
|
||||||
|
|
||||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||||
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
|
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
|
||||||
|
|||||||
@@ -20,15 +20,22 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
|
|
||||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||||
|
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
|
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
|
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public class FakeTelevisionRepository : ITelevisionRepository
|
public class FakeTelevisionRepository : ITelevisionRepository
|
||||||
{
|
{
|
||||||
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
||||||
|
public Task<bool> AllSeasonsExist(List<int> seasonIds) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
|
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||||
@@ -18,6 +20,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
||||||
|
public Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
|
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||||
@@ -33,8 +37,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
|
|
||||||
public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException();
|
public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<Option<Episode>> GetEpisode(int episodeId) => throw new NotSupportedException();
|
|
||||||
|
|
||||||
public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
|
public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
|
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
|
||||||
@@ -95,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||||
|
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,6 +406,48 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
Times.Once);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Should_Ignore_Dot_Underscore_Files(
|
||||||
|
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
|
||||||
|
string videoExtension)
|
||||||
|
{
|
||||||
|
string moviePath = Path.Combine(
|
||||||
|
FakeRoot,
|
||||||
|
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
|
||||||
|
|
||||||
|
MovieFolderScanner service = GetService(
|
||||||
|
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
|
||||||
|
new FakeFileEntry(
|
||||||
|
Path.Combine(
|
||||||
|
Path.GetDirectoryName(moviePath) ?? string.Empty,
|
||||||
|
$"._Movie (2020){videoExtension}"))
|
||||||
|
);
|
||||||
|
var libraryPath = new LibraryPath
|
||||||
|
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
|
||||||
|
|
||||||
|
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||||
|
libraryPath,
|
||||||
|
FFprobePath,
|
||||||
|
0,
|
||||||
|
1);
|
||||||
|
|
||||||
|
result.IsRight.Should().BeTrue();
|
||||||
|
|
||||||
|
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
|
||||||
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
|
_localStatisticsProvider.Verify(
|
||||||
|
x => x.RefreshStatistics(
|
||||||
|
FFprobePath,
|
||||||
|
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||||
|
Times.Once);
|
||||||
|
|
||||||
|
_localMetadataProvider.Verify(
|
||||||
|
x => x.RefreshFallbackMetadata(
|
||||||
|
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Should_Ignore_Extra_Folders(
|
public async Task Should_Ignore_Extra_Folders(
|
||||||
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
|
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Domain
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public List<Collection> Collections { get; set; }
|
public List<Collection> Collections { get; set; }
|
||||||
|
public List<SmartCollection> SmartCollections { get; set; }
|
||||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
||||||
|
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class MultiCollectionSmartItem
|
||||||
|
{
|
||||||
|
public int MultiCollectionId { get; set; }
|
||||||
|
public MultiCollection MultiCollection { get; set; }
|
||||||
|
public int SmartCollectionId { get; set; }
|
||||||
|
public SmartCollection SmartCollection { get; set; }
|
||||||
|
public bool ScheduleAsGroup { get; set; }
|
||||||
|
public PlaybackOrder PlaybackOrder { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class SmartCollection
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Query { get; set; }
|
||||||
|
public List<MultiCollection> MultiCollections { get; set; }
|
||||||
|
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class TraktList
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int TraktId { get; set; }
|
||||||
|
public string User { get; set; }
|
||||||
|
public string List { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public int ItemCount { get; set; }
|
||||||
|
public List<TraktListItem> Items { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class TraktListItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int TraktListId { get; set; }
|
||||||
|
public TraktList TraktList { get; set; }
|
||||||
|
|
||||||
|
public TraktListItemKind Kind { get; set; }
|
||||||
|
public int TraktId { get; set; }
|
||||||
|
public int Rank { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public int? Season { get; set; }
|
||||||
|
public int? Episode { get; set; }
|
||||||
|
public List<TraktListItemGuid> Guids { get; set; }
|
||||||
|
|
||||||
|
public int? MediaItemId { get; set; }
|
||||||
|
public MediaItem MediaItem { get; set; }
|
||||||
|
|
||||||
|
public string DisplayTitle => Kind switch
|
||||||
|
{
|
||||||
|
TraktListItemKind.Movie => $"{Title} ({Year})",
|
||||||
|
TraktListItemKind.Show => $"{Title} ({Year})",
|
||||||
|
TraktListItemKind.Season => $"{Title} ({Year}) S{Season:00}",
|
||||||
|
_ => $"{Title} ({Year}) S{Season:00}E{Episode:00}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public class TraktListItemGuid
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Guid { get; set; }
|
||||||
|
public int TraktListItemId { get; set; }
|
||||||
|
public TraktListItem TraktListItem { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ErsatzTV.Core.Domain
|
||||||
|
{
|
||||||
|
public enum TraktListItemKind
|
||||||
|
{
|
||||||
|
Movie,
|
||||||
|
Show,
|
||||||
|
Season,
|
||||||
|
Episode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,16 +13,19 @@
|
|||||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||||
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
|
||||||
|
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
|
||||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||||
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
|
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
|
||||||
|
public static ConfigElementKey SmartCollectionsPageSize => new("pages.smart_collections.page_size");
|
||||||
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
|
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
|
||||||
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
|
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
|
||||||
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
|
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
|
||||||
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
||||||
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
||||||
|
public static ConfigElementKey TraktListsPageSize => new("pages.trakt.lists_page_size");
|
||||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using System.Diagnostics;
|
|||||||
|
|
||||||
namespace ErsatzTV.Core.Domain
|
namespace ErsatzTV.Core.Domain
|
||||||
{
|
{
|
||||||
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
|
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
|
||||||
public class Episode : MediaItem
|
public class Episode : MediaItem
|
||||||
{
|
{
|
||||||
public int SeasonId { get; set; }
|
public int SeasonId { get; set; }
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Domain
|
|||||||
public LibraryPath LibraryPath { get; set; }
|
public LibraryPath LibraryPath { get; set; }
|
||||||
public List<Collection> Collections { get; set; }
|
public List<Collection> Collections { get; set; }
|
||||||
public List<CollectionItem> CollectionItems { get; set; }
|
public List<CollectionItem> CollectionItems { get; set; }
|
||||||
|
public List<TraktListItem> TraktListItems { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Domain
|
namespace ErsatzTV.Core.Domain
|
||||||
{
|
{
|
||||||
@@ -13,5 +14,6 @@ namespace ErsatzTV.Core.Domain
|
|||||||
public List<PlayoutItem> Items { get; set; }
|
public List<PlayoutItem> Items { get; set; }
|
||||||
public PlayoutAnchor Anchor { get; set; }
|
public PlayoutAnchor Anchor { get; set; }
|
||||||
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
|
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
|
||||||
|
public TimeSpan? DailyRebuildTime { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
public Collection Collection { get; set; }
|
public Collection Collection { get; set; }
|
||||||
public int? MultiCollectionId { get; set; }
|
public int? MultiCollectionId { get; set; }
|
||||||
public MultiCollection MultiCollection { get; set; }
|
public MultiCollection MultiCollection { get; set; }
|
||||||
|
public int? SmartCollectionId { get; set; }
|
||||||
|
public SmartCollection SmartCollection { get; set; }
|
||||||
public int? MediaItemId { get; set; }
|
public int? MediaItemId { get; set; }
|
||||||
public MediaItem MediaItem { get; set; }
|
public MediaItem MediaItem { get; set; }
|
||||||
public CollectionEnumeratorState EnumeratorState { get; set; }
|
public CollectionEnumeratorState EnumeratorState { get; set; }
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ namespace ErsatzTV.Core.Domain
|
|||||||
public MediaItem MediaItem { get; set; }
|
public MediaItem MediaItem { get; set; }
|
||||||
public int? MultiCollectionId { get; set; }
|
public int? MultiCollectionId { get; set; }
|
||||||
public MultiCollection MultiCollection { get; set; }
|
public MultiCollection MultiCollection { get; set; }
|
||||||
|
public int? SmartCollectionId { get; set; }
|
||||||
|
public SmartCollection SmartCollection { get; set; }
|
||||||
public PlaybackOrder PlaybackOrder { get; set; }
|
public PlaybackOrder PlaybackOrder { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
TelevisionShow = 1,
|
TelevisionShow = 1,
|
||||||
TelevisionSeason = 2,
|
TelevisionSeason = 2,
|
||||||
Artist = 3,
|
Artist = 3,
|
||||||
MultiCollection = 4
|
MultiCollection = 4,
|
||||||
|
SmartCollection = 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
|
|||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public int Height { get; set; }
|
public int Height { get; set; }
|
||||||
public int Width { get; set; }
|
public int Width { get; set; }
|
||||||
|
|
||||||
|
public override string ToString() => $"{Width}x{Height}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Emby
|
|||||||
incoming.ShowId = show.Id;
|
incoming.ShowId = show.Id;
|
||||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||||
|
|
||||||
await _televisionRepository.Update(incoming);
|
foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
|
||||||
|
{
|
||||||
|
incoming.Show = show;
|
||||||
|
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
@@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Emby
|
|||||||
show.ShowMetadata.Head().Title,
|
show.ShowMetadata.Head().Title,
|
||||||
incoming.SeasonMetadata.Head().Title);
|
incoming.SeasonMetadata.Head().Title);
|
||||||
|
|
||||||
await _televisionRepository.AddSeason(show, incoming);
|
if (await _televisionRepository.AddSeason(show, incoming))
|
||||||
|
{
|
||||||
|
incoming.Show = show;
|
||||||
|
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
List<EmbyItemEtag> existingEpisodes =
|
List<EmbyItemEtag> existingEpisodes =
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -129,6 +129,20 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string[] h264hevc = { "h264", "hevc" };
|
||||||
|
|
||||||
|
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||||
|
h264hevc.Contains(_inputCodec)
|
||||||
|
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||||
|
{
|
||||||
|
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" && h264hevc.Contains(_inputCodec))
|
||||||
|
{
|
||||||
|
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||||
|
}
|
||||||
|
|
||||||
_scaleToSize.IfSome(
|
_scaleToSize.IfSome(
|
||||||
size =>
|
size =>
|
||||||
{
|
{
|
||||||
@@ -138,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
|
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,28 +247,39 @@ namespace ErsatzTV.Core.FFmpeg
|
|||||||
complexFilter.Append(audioLabel);
|
complexFilter.Append(audioLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoFilterQueue.Any())
|
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||||
{
|
{
|
||||||
if (hasAudioFilters)
|
if (hasAudioFilters)
|
||||||
{
|
{
|
||||||
complexFilter.Append(';');
|
complexFilter.Append(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
complexFilter.Append($"[{videoLabel}]");
|
if (videoFilterQueue.Any())
|
||||||
var filters = string.Join(",", videoFilterQueue);
|
{
|
||||||
complexFilter.Append(filters);
|
complexFilter.Append($"[{videoLabel}]");
|
||||||
|
var filters = string.Join(",", videoFilterQueue);
|
||||||
|
complexFilter.Append(filters);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
|
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||||
{
|
{
|
||||||
complexFilter.Append("[vt]");
|
if (videoFilterQueue.Any())
|
||||||
|
{
|
||||||
|
complexFilter.Append("[vt];");
|
||||||
|
}
|
||||||
|
|
||||||
var watermarkLabel = "[1:v]";
|
var watermarkLabel = "[1:v]";
|
||||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||||
{
|
{
|
||||||
complexFilter.Append($";{watermarkLabel}{watermarkPreprocess}[wmp]");
|
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||||
watermarkLabel = "[wmp]";
|
watermarkLabel = "[wmp]";
|
||||||
}
|
}
|
||||||
|
|
||||||
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
|
complexFilter.Append(
|
||||||
|
videoFilterQueue.Any()
|
||||||
|
? $"[vt]{watermarkLabel}{watermarkOverlay}"
|
||||||
|
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
|
||||||
|
|
||||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||||
{
|
{
|
||||||
complexFilter.Append(",hwupload");
|
complexFilter.Append(",hwupload");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user