Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 1802f9d797 fix database migration (#436) 2021-10-15 11:31:40 -05:00
Jason DoveandGitHub 69354c9296 fix double scheduling (#435) 2021-10-15 09:14:53 -05:00
Jason Dove 0021e21b50 fix other video playback 2021-10-14 22:53:32 -05:00
Jason Dove cdf7765059 update changelog for release v0.1.4-alpha [no ci] 2021-10-14 15:00:24 -05:00
Jason DoveandGitHub 71658c448f update docs (#431) 2021-10-14 14:41:12 -05:00
Jason DoveandGitHub 3ecdd741a5 add guide mode to schedule items (#430) 2021-10-14 13:24:54 -05:00
Jason DoveandGitHub 0daeb844b9 add other videos library kind (#429) 2021-10-14 12:58:37 -05:00
Jason DoveandGitHub 22da19845b add filler option to duration playout mode (#428)
* add duration tail options to schedule items editor

* add naive filler scheduling

* fix duration item length in xmltv

* show offline image for unfilled duration tail

* fix tests

* update changelog

* update dependencies
2021-10-13 21:15:16 -05:00
Jason Dove 3a6d9e9f39 update changelog for release v0.1.3-alpha [no ci] 2021-10-13 15:17:23 -05:00
Jason DoveandGitHub 7ed4b8ae3c fix startup bug (#426) 2021-10-13 13:30:26 -05:00
Jason Dove be7311e620 update changelog for release v0.1.2-alpha [no ci] 2021-10-12 19:00:30 -05:00
Jason Dove 03be372070 update changelog [no ci] 2021-10-12 18:57:02 -05:00
Jason DoveandGitHub d196308ee9 fix ffmpeg profile editing (#421) 2021-10-12 18:39:51 -05:00
Jason Dove 3d68b0f055 fix vaapi migration 2021-10-12 18:22:09 -05:00
Jason Dove 37e32f06ad update changelog [no ci] 2021-10-12 17:56:56 -05:00
Jason DoveandGitHub c43ca2837d support radeon vaapi acceleration (#420) 2021-10-12 17:51:55 -05:00
Jason DoveandGitHub 992121f308 add more watermark locations (#419) 2021-10-12 07:19:52 -05:00
Jason DoveandGitHub 04adbfeffa add hls segmenter settings to optimize performance (#418)
* add hls segmenter settings to optimize performance

* use consistent setting defaults
2021-10-12 06:31:11 -05:00
Jason DoveandGitHub 1fc905c6ad upgrade vaapi to ffmpeg 4.4 (#417) 2021-10-11 22:26:32 -05:00
Jason DoveandGitHub 4b5dff2159 ffnvcodec fixes (#416) 2021-10-11 22:14:43 -05:00
Jason DoveandGitHub 2a5edf8214 ffmpeg 4.4 llvm nvidia fixes (#415) 2021-10-11 21:31:59 -05:00
Jason DoveandGitHub 69912c8cae support ffmpeg 4.4 (#414)
* support ffmpeg 4.4

* update changelog
2021-10-11 20:16:15 -05:00
Jason DoveandGitHub fd3de2d82a nvidia 10 bit fixes (#413) 2021-10-11 16:00:35 -05:00
Jason DoveandGitHub 6ba9404752 nvidia transcoding improvements (#412)
* nvidia transcoding fixes

* use yadif_cuda to deinterlace
2021-10-10 22:40:43 -05:00
Jason Dove db080375c5 update changelog for release v0.1.1-alpha [no ci] 2021-10-10 12:54:46 -05:00
Jason Dove 9abc7ad8b7 try to fix tests on windows 2021-10-10 12:39:08 -05:00
Jason DoveandGitHub 9e531a82d7 add some hls playlist filter tests (#411) 2021-10-10 12:33:02 -05:00
Jason DoveandGitHub d84bd2b948 upgrade nvidia docker image (#410) 2021-10-10 11:45:02 -05:00
Jason DoveandGitHub d7d3ec1235 add music video album to search index (#409)
* add music video album to search index

* update search docs
2021-10-10 10:28:35 -05:00
Jason Dove 742ac21ad7 update collection docs [no docker] 2021-10-10 07:53:29 -05:00
Jason DoveandGitHub 819b55e21f increase max hls segments (#408) 2021-10-10 06:47:24 -05:00
Jason DoveandGitHub cf5718c288 rework hls segmenter (#407)
* rework hls segmenter to start more quickly

* don't use realtime encoding for hls until we're at least a minute ahead

* ugly but functional playlist filtering
2021-10-09 22:46:38 -05:00
Jason DoveandGitHub adc7982955 reduce initial hls segmenter delay (#406) 2021-10-09 10:26:57 -05:00
Jason Dove 67a6f554d0 rename v0.0.63-alpha v0.1.0-alpha [no ci] 2021-10-08 18:44:49 -05:00
Jason Dove 609df217ae update changelog for release 63 [no ci] 2021-10-08 18:39:03 -05:00
Jason DoveandGitHub d3086264c7 unraid doc fixes (#405) 2021-10-08 18:31:22 -05:00
Jason DoveandGitHub 8cd9b23787 fix transcode folder preparation (#404) 2021-10-08 15:29:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc5c9e42ff Bump Serilog.Settings.Configuration from 3.2.0 to 3.3.0 (#403)
Bumps [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/serilog/serilog-settings-configuration/releases)
- [Changelog](https://github.com/serilog/serilog-settings-configuration/blob/dev/CHANGES.md)
- [Commits](https://github.com/serilog/serilog-settings-configuration/commits)

---
updated-dependencies:
- dependency-name: Serilog.Settings.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-08 09:09:57 -05:00
Jason DoveandGitHub 2dd267e4db fix xmltv generation with missing episode metadata (#402) 2021-10-07 22:31:46 -05:00
Jason DoveandGitHub b069a21473 allow hls segmenter to run before framerate is known (#401) 2021-10-07 22:07:54 -05:00
Jason DoveandGitHub 6c8813ce22 add hls segmenter streaming mode (#400)
* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

* decrease segmenter start delay
2021-10-07 21:42:29 -05:00
Jason DoveandGitHub b5de5e2b7f fix statistic updates (#399) 2021-10-07 19:56:41 -05:00
Jason DoveandGitHub 4b7da4e468 speed up builds by using base images (#398) 2021-10-06 06:06:13 -05:00
Jason DoveandGitHub ae8e795228 vaapi downsample 10bit hevc 8bit h264 (#397)
* vaapi downsample 10bit hevc to 8bit h264

* update changelog
2021-10-05 22:16:57 -05:00
Jason DoveandGitHub 334781485d use latest intel driver in vaapi docker images (#396)
* compile latest iHD driver

* cleanup to reduce image size

* update changelog
2021-10-05 20:24:26 -05:00
Jason Dove 27fefa1b38 update changelog for release 62 [no ci] 2021-10-05 15:28:45 -05:00
Jason DoveandGitHub fc3175591e use libx264 for all errors (#395) 2021-10-05 15:13:35 -05:00
Jason DoveandGitHub 3363d2c9d7 update plex paths when they are changed (#394) 2021-10-05 06:27:05 -05:00
Jason DoveandGitHub 1d5217fa84 support imdb ids from plex (#393) 2021-10-05 06:01:53 -05:00
Jason DoveandGitHub 904cdb8780 vaapi improvements (#392)
* add transcoding tests

* dont use full paths for transcoding tests

* add error details

* use 1-second videos for transcoding tests

* vaapi fixes

* include format in scale_vaapi

* more vaapi fixes

* unsupported errors

* fix unsupported checks

* maybe not failure?

* fix formatting

* ignore nvdec warnings

* update changelog

* fix tests
2021-10-02 13:42:44 -05:00
Jason Dove 85fee64565 update changelog [no ci] 2021-10-01 13:44:04 -05:00
Jason DoveandGitHub 13cfb9728f include season zero episode-num in xmltv (#391) 2021-10-01 13:42:46 -05:00
202 changed files with 38365 additions and 435 deletions
+87 -1
View File
@@ -4,6 +4,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix double scheduling; this could happen if the app was shutdown during a playout build
## [0.1.4-alpha] - 2021-10-14
### Fixed
- Fix error message/offline stream continuity with channels that use HLS Segmenter
- Fix removing items from search index when folders are removed from local libraries
### Added
- Add `Other Video` local libraries
- Other video items require no metadata or particular folder layout, and will have tags added for each containing folder
- For Example, a video at `commercials/sd/1990/whatever.mkv` will have the tags `commercials`, `sd` and `1990`, and the title `whatever`
- Add filler `Tail Mode` option to `Duration` playout mode (in addition to existing `Offline` option)
- Filler collection will always be randomized (to fill as much time as possible)
- Filler will be hidden from channel guide, but visible in playout details in ErsatzTV
- Unfilled time will show offline image
- Add `Guide Mode` option to all schedule items
- `Normal` guide mode will show all scheduled items in the channel guide (xmltv)
- `Filler` guide mode will hide all scheduled items from the channel guide, and extend the end time for the previous item in the guide
## [0.1.3-alpha] - 2021-10-13
### Fixed
- Fix startup bug for some docker installations
## [0.1.2-alpha] - 2021-10-12
### Added
- Include more cuda (nvidia) filters in docker image
- Enable deinterlacing with nvidia using new `yadif_cuda` filter
- Add two HLS Segmenter settings: idle timeout and work-ahead limit
- `HLS Segmenter Idle Timeout` - the number of seconds to keep transcoding a channel while no requests have been received from any client
- This setting must be greater than or equal to 30 (seconds)
- `Work-Ahead HLS Segmenter Limit` - the number of segmenters (channels) that will work-ahead simultaneously (if multiple channels are being watched)
- "working ahead" means transcoding at full speed, which can take a lot of resources
- This setting must be greater than or equal to 0
- Add more watermark locations ("middle" of each side)
- Add `VAAPI Device` setting to ffmpeg profile to support installations with multiple video cards
- Add *experimental* `RadeonSI` option for `VAAPI Driver` and include mesa drivers in vaapi docker image
### Changed
- Upgrade ffmpeg from 4.3 to 4.4 in all docker images
- Upgrading from 4.3 to 4.4 is recommended for all installations
- Move `VAAPI Driver` from settings page to ffmpeg profile to support installations with multiple video cards
### Fixed
- Fix some transcoding edge cases with nvidia and pixel format `yuv420p10le`
## [0.1.1-alpha] - 2021-10-10
### Added
- Add music video album to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove forced initial delay from `HLS Segmenter` streaming mode
- Upgrade nvidia docker image from 18.04 to 20.04
## [0.1.0-alpha] - 2021-10-08
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
- This mode is intended to increase client compatibility and reduce issues at program boundaries
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
- Store frame rate with media statistics; this is needed to support HLS Segmenter
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images
### Fixed
- Add downsampling to support transcoding 10-bit HEVC content with the h264_vaapi encoder
- Fix updating statistics when media items are replaced
- Fix XMLTV generation when scheduled episode is missing metadata
## [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
@@ -634,7 +714,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...HEAD
[0.1.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...v0.1.4-alpha
[0.1.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[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
@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
@@ -7,10 +7,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -10,6 +11,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
@@ -45,6 +45,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -11,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
@@ -36,6 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
@@ -116,8 +116,12 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegVaapiDriver,
(int)request.Settings.VaapiDriver);
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit);
return Unit.Default;
}
@@ -1,5 +1,6 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -9,6 +10,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution,
bool NormalizeVideo,
string VideoCodec,
@@ -1,6 +1,4 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
namespace ErsatzTV.Application.FFmpegProfiles
{
public class FFmpegSettingsViewModel
{
@@ -10,6 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
}
}
@@ -12,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution),
profile.NormalizeVideo,
profile.VideoCodec,
@@ -1,7 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -29,8 +28,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> vaapiDriver =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
var result = new FFmpegSettingsViewModel
{
@@ -39,7 +40,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
};
foreach (int watermarkId in watermark)
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
@@ -8,6 +9,7 @@ using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@@ -22,15 +24,18 @@ namespace ErsatzTV.Application.Libraries.Commands
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
}
@@ -56,10 +61,21 @@ namespace ErsatzTV.Application.Libraries.Commands
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.ToList();
var toRemoveIds = toRemove.Map(lp => lp.Id).ToList();
List<int> itemsToRemove = await dbContext.MediaItems
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
.Map(mi => mi.Id)
.ToListAsync();
existing.Paths.RemoveAll(toRemove.Contains);
existing.Paths.AddRange(toAdd);
await dbContext.SaveChangesAsync();
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchIndex.RemoveItems(itemsToRemove);
_searchIndex.Commit();
}
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
{
@@ -9,7 +9,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
+11 -2
View File
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
@@ -101,8 +100,16 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@@ -133,6 +140,8 @@ namespace ErsatzTV.Application.MediaCards
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(
@@ -7,6 +7,7 @@
string Subtitle,
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardResultsViewModel(
int Count,
List<OtherVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,17 @@
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
{
public int CustomIndex { get; set; }
}
}
@@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -56,6 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddOtherVideoToCollection
(int CollectionId, int OtherVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
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;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddOtherVideoToCollectionHandler :
MediatR.IRequestHandler<AddOtherVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddOtherVideoToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddOtherVideoToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddOtherVideoToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateOtherVideo(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddOtherVideoToCollection request) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Task<Validation<BaseError, OtherVideo>> ValidateOtherVideo(
TvContext dbContext,
AddOtherVideoToCollection request) =>
dbContext.OtherVideos
.SelectOneAsync(m => m.Id, e => e.Id == request.OtherVideoId)
.Map(o => o.ToValidation<BaseError>("OtherVideo does not exist"));
private record Parameters(Collection Collection, OtherVideo OtherVideo);
}
}
@@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
@@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
@@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin,
progressMax);
break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;
+4
View File
@@ -37,6 +37,9 @@ namespace ErsatzTV.Application.Playouts
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Title ?? string.Empty)
.IfNone("[unknown video]");
default:
return string.Empty;
}
@@ -49,6 +52,7 @@ namespace ErsatzTV.Application.Playouts
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetFuturePlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -11,21 +12,23 @@ using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts.Queries
{
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel>
public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayoutItemsById, PagedPlayoutItemsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<PagedPlayoutItemsViewModel> Handle(
GetPlayoutItemsById request,
GetFuturePlayoutItemsById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
int totalCount = await dbContext.PlayoutItems
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken);
DateTime now = DateTimeOffset.Now.UtcDateTime;
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
@@ -49,7 +52,12 @@ namespace ErsatzTV.Application.Playouts.Queries
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now)
.OrderBy(i => i.Start)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -19,6 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}
@@ -15,7 +15,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
TailMode TailMode { get; }
ProgramScheduleItemCollectionType TailCollectionType { get; }
int? TailCollectionId { get; }
int? TailMultiCollectionId { get; }
int? TailSmartCollectionId { get; }
int? TailMediaItemId { get; }
string CustomTitle { get; }
GuideMode GuideMode { get; }
}
}
@@ -55,11 +55,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'");
}
if (item.OfflineTail is null)
{
return BaseError.New("[OfflineTail] is required for playout mode 'duration'");
}
break;
default:
return BaseError.New("[PlayoutMode] is invalid");
@@ -140,7 +135,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -153,7 +149,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -167,7 +164,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -181,8 +179,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
TailMode = item.TailMode,
TailCollectionType = item.TailCollectionType,
TailCollectionId = item.TailCollectionId,
TailMultiCollectionId = item.TailMultiCollectionId,
TailSmartCollectionId = item.TailSmartCollectionId,
TailMediaItemId = item.TailMediaItemId,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -20,8 +20,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -40,8 +40,26 @@ namespace ErsatzTV.Application.ProgramSchedules
},
duration.PlaybackOrder,
duration.PlayoutDuration,
duration.OfflineTail,
duration.CustomTitle),
duration.TailMode,
duration.TailCollectionType,
duration.TailCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailCollection)
: null,
duration.TailMultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailMultiCollection)
: null,
duration.TailSmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailSmartCollection)
: null,
duration.TailMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.CustomTitle,
duration.GuideMode),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -66,7 +84,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
flood.PlaybackOrder,
flood.CustomTitle),
flood.CustomTitle,
flood.GuideMode),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -92,7 +111,8 @@ namespace ErsatzTV.Application.ProgramSchedules
},
multiple.PlaybackOrder,
multiple.Count,
multiple.CustomTitle),
multiple.CustomTitle,
multiple.GuideMode),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -117,7 +137,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
one.PlaybackOrder,
one.CustomTitle),
one.CustomTitle,
one.GuideMode),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
@@ -19,8 +19,14 @@ namespace ErsatzTV.Application.ProgramSchedules
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
bool offlineTail,
string customTitle) : base(
TailMode tailMode,
ProgramScheduleItemCollectionType tailCollectionType,
MediaCollectionViewModel tailCollection,
MultiCollectionViewModel tailMultiCollection,
SmartCollectionViewModel tailSmartCollection,
NamedMediaItemViewModel tailMediaItem,
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -32,13 +38,26 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
TailMode = tailMode;
TailCollectionType = tailCollectionType;
TailCollection = tailCollection;
TailMultiCollection = tailMultiCollection;
TailSmartCollection = tailSmartCollection;
TailMediaItem = tailMediaItem;
}
public TimeSpan PlayoutDuration { get; }
public bool OfflineTail { get; }
public TailMode TailMode { get; }
public ProgramScheduleItemCollectionType TailCollectionType { get; }
public MediaCollectionViewModel TailCollection { get; }
public MultiCollectionViewModel TailMultiCollection { get; }
public SmartCollectionViewModel TailSmartCollection { get; }
public NamedMediaItemViewModel TailMediaItem { get; }
}
}
@@ -18,7 +18,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -30,7 +31,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}
@@ -19,7 +19,8 @@ namespace ErsatzTV.Application.ProgramSchedules
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -31,7 +32,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>
customTitle,
guideMode) =>
Count = count;
public int Count { get; }
@@ -18,7 +18,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -30,7 +31,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
string CustomTitle,
GuideMode GuideMode)
{
public string Name => CollectionType switch
{
@@ -30,6 +30,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.Include(i => (i as ProgramScheduleItemDuration).TailCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMultiCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailSmartCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using MediatR;
@@ -19,12 +20,13 @@ namespace ErsatzTV.Application.Search.Queries
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("season", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
await GetIds(SearchIndex.MovieType, request.Query),
await GetIds(SearchIndex.ShowType, request.Query),
await GetIds(SearchIndex.SeasonType, request.Query),
await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query));
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexOtherVideos
(string Query, int PageNumber, int PageSize) : IRequest<OtherVideoCardResultsViewModel>;
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
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
QuerySearchIndexOtherVideosHandler : IRequestHandler<QuerySearchIndexOtherVideos,
OtherVideoCardResultsViewModel>
{
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexOtherVideosHandler(ISearchIndex searchIndex, IOtherVideoRepository otherVideoRepository)
{
_searchIndex = searchIndex;
_otherVideoRepository = otherVideoRepository;
}
public async Task<OtherVideoCardResultsViewModel> Handle(
QuerySearchIndexOtherVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<OtherVideoCardViewModel> items = await _otherVideoRepository
.GetOtherVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new OtherVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Search
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
List<int> MusicVideoIds,
List<int> OtherVideoIds);
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository)
{
_localFileSystem = localFileSystem;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => StartProcess(request))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
#pragma warning disable VSTHRD103
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
#pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
TimeSpan idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
using IServiceScope scope = _serviceScopeFactory.CreateScope();
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
while (!File.Exists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch();
}
return result.AsTask();
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -0,0 +1,243 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming
{
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _logger;
private DateTimeOffset _lastAccess;
private DateTimeOffset _transcodedUntil;
private Timer _timer;
private readonly object _sync = new();
private DateTimeOffset _playlistStart;
public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public DateTimeOffset PlaylistStart => _playlistStart;
public void Touch()
{
lock (_sync)
{
_lastAccess = DateTimeOffset.Now;
_timer?.Stop();
_timer?.Start();
}
}
public async Task Run(string channelNumber, TimeSpan idleTimeout)
{
var cts = new CancellationTokenSource();
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
try
{
lock (_sync)
{
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += Cancel;
}
CancellationToken cancellationToken = cts.Token;
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
Touch();
_transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil;
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, true, !initialWorkAhead, cancellationToken))
{
return;
}
while (!cancellationToken.IsCancellationRequested)
{
if (DateTimeOffset.Now - _lastAccess > idleTimeout)
{
_logger.LogInformation("Stopping idle HLS session for channel {Channel}", channelNumber);
return;
}
var transcodedBuffer = TimeSpan.FromSeconds(
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
if (transcodedBuffer <= TimeSpan.FromMinutes(1))
{
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, false, !subsequentWorkAhead, cancellationToken))
{
return;
}
}
else
{
await TrimAndDelete(channelNumber, cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
}
finally
{
lock (_sync)
{
_timer.Elapsed -= Cancel;
}
}
}
private async Task<bool> Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken)
{
try
{
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", channelNumber);
}
else
{
_logger.LogInformation(
"HLS segmenter will NOT work ahead for channel {Channel}",
channelNumber);
}
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
realtime);
// _logger.LogInformation("Request {@Request}", request);
Either<BaseError, PlayoutItemProcessModel> result = await mediator.Send(request, cancellationToken);
// _logger.LogInformation("Result {Result}", result.ToString());
foreach (BaseError error in result.LeftAsEnumerable())
{
_logger.LogWarning(
"Failed to create process for HLS session on channel {Channel}: {Error}",
channelNumber,
error.ToString());
return false;
}
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
{
await TrimAndDelete(channelNumber, cancellationToken);
Process process = processModel.Process;
_logger.LogDebug(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken);
process.WaitForExit();
}
catch (TaskCanceledException)
{
_logger.LogInformation("Terminating HLS process for channel {Channel}", channelNumber);
process.Kill();
process.WaitForExit();
return false;
}
_logger.LogInformation("HLS process has completed for channel {Channel}", channelNumber);
_transcodedUntil = processModel.Until;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber);
return false;
}
finally
{
Interlocked.Decrement(ref _workAheadCount);
}
return true;
}
private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken)
{
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
channelNumber,
"live.m3u8");
if (File.Exists(playlistFileName))
{
// trim playlist and insert discontinuity before appending with new ffmpeg process
string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken);
TrimPlaylistResult trimResult = HlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_playlistStart,
DateTimeOffset.Now.AddMinutes(-1),
lines);
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken);
// delete old segments
foreach (string file in Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
"*.ts"))
{
string fileName = Path.GetFileName(file);
if (fileName.StartsWith("live") && int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]) <
trimResult.Sequence)
{
File.Delete(file);
}
}
_playlistStart = trimResult.PlaylistStart;
}
}
private async Task<int> GetWorkAheadLimit()
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
return await repo.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
}
}
}
@@ -0,0 +1,7 @@
using System;
using System.Diagnostics;
namespace ErsatzTV.Application.Streaming
{
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
}
@@ -1,5 +1,4 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +13,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, Process>>
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, PlayoutItemProcessModel>>
where T : FFmpegProcessRequest
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -22,16 +21,16 @@ namespace ErsatzTV.Application.Streaming.Queries
protected FFmpegProcessHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Process>> Handle(T request, CancellationToken cancellationToken)
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
error => Task.FromResult<Either<BaseError, Process>>(error.Join()));
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
}
protected abstract Task<Either<BaseError, Process>> GetProcess(
protected abstract Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
T request,
Channel channel,
@@ -56,6 +55,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
@@ -1,9 +1,15 @@
using System.Diagnostics;
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest
(
string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}
@@ -1,10 +1,15 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
"ts",
DateTimeOffset.Now,
false,
true)
{
Scheme = scheme;
Host = host;
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -27,7 +28,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetConcatProcessByChannelNumber request,
Channel channel,
@@ -37,12 +38,14 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel(
Process process = _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -1,8 +1,20 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
public GetPlayoutItemProcessByChannelNumber(
string channelNumber,
string mode,
DateTimeOffset now,
bool startAtZero,
bool hlsRealtime) : base(
channelNumber,
mode,
now,
startAtZero,
hlsRealtime)
{
}
}
@@ -48,13 +48,14 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetPlayoutItemProcessByChannelNumber _,
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset now = request.Now;
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
@@ -74,6 +75,12 @@ namespace ErsatzTV.Application.Streaming.Queries
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(ov => ov.Streams)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
@@ -86,6 +93,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
@@ -99,21 +107,22 @@ namespace ErsatzTV.Application.Streaming.Queries
watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
.MapT(i => (VaapiDriver)i);
Process process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
request.HlsRealtime);
return Right<BaseError, Process>(
await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now,
maybeGlobalWatermark,
maybeVaapiDriver));
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
return Right<BaseError, PlayoutItemProcessModel>(result);
},
async error =>
{
@@ -132,16 +141,22 @@ namespace ErsatzTV.Application.Streaming.Queries
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
switch (error)
{
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForError(
Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
"Channel is Offline",
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
@@ -153,7 +168,14 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForError(ffmpegPath, channel, maybeDuration, error.Value);
Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
@@ -165,11 +187,14 @@ namespace ErsatzTV.Application.Streaming.Queries
default:
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForError(
Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
"Channel is Offline",
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
@@ -201,6 +226,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
@@ -6,10 +6,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
@@ -341,43 +341,24 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
// TODO: get yadif_cuda working in docker
// [TestCase(true, false, false, "[0:V]yadif_cuda[v]", "[v]")]
// [TestCase(
// true,
// true,
// false,
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// false,
// true,
// "[0:V]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// true,
// true,
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
[TestCase(true, false, false, "[0:0]yadif_cuda[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
@@ -437,7 +418,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
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]")]
[TestCase(
"h264",
@@ -451,14 +432,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
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]")]
[TestCase(
"h264",
false,
true,
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]")]
[TestCase(
"h264",
@@ -472,7 +453,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
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]")]
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
[TestCase(
@@ -480,7 +461,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
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]")]
[TestCase(
"mpeg4",
@@ -494,14 +475,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
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]")]
[TestCase(
"mpeg4",
false,
true,
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]")]
[TestCase(
"mpeg4",
@@ -515,7 +496,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
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]")]
public void Should_Return_VAAPI_Video_Filter(
string codec,
@@ -0,0 +1,223 @@
using System;
using ErsatzTV.Core.FFmpeg;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.FFmpeg
{
[TestFixture]
public class HlsPlaylistFilterTests
{
[Test]
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldLimitSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
start,
start.AddSeconds(-30),
input,
int.MaxValue,
true);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
#EXT-X-DISCONTINUITY
");
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
}
}
@@ -0,0 +1,250 @@
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();
// ReSharper disable once MethodHasAsyncOverload
p1.WaitForExit();
p1.ExitCode.Should().Be(0);
}
var service = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<IImageCache>().Object,
new Mock<ILogger<FFmpegProcessService>>().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<LocalFileSystem>>().Object),
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,
VaapiDriver.Default,
"/dev/dri/renderD128",
false);
process.StartInfo.RedirectStandardError = true;
process.Start().Should().BeTrue();
process.BeginOutputReadLine();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
// ReSharper disable once MethodHasAsyncOverload
process.WaitForExit();
string[] unsupportedMessages =
{
"No support for codec",
"No usable",
"Provided device doesn't support"
};
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
{
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
}
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
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
process.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
}
}
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();
}
}
}
@@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();
@@ -97,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => 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();
}
}
@@ -709,7 +709,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(2),
PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false, // immediately continue
TailMode = TailMode.None, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -807,7 +807,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = dynamicCollection.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(2),
OfflineTail = false, // immediately continue
TailMode = TailMode.None, // immediately continue
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -1089,7 +1089,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false,
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
@@ -1100,7 +1100,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
CollectionId = collectionTwo.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false,
TailMode = TailMode.None,
PlaybackOrder = PlaybackOrder.Chronological
}
};
+5 -1
View File
@@ -22,7 +22,11 @@
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3
TopLeft = 3,
TopMiddle = 4,
RightMiddle = 5,
BottomMiddle = 6,
LeftMiddle = 7
}
public enum ChannelWatermarkSize
+2 -1
View File
@@ -13,7 +13,8 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
+7 -2
View File
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Domain
{
public record FFmpegProfile
{
@@ -7,6 +9,8 @@
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public string VideoCodec { get; set; }
@@ -39,7 +43,8 @@
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeVideo = true,
NormalizeAudio = true
NormalizeAudio = true,
HardwareAcceleration = HardwareAccelerationKind.None
};
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum GuideMode
{
Normal = 0,
Filler = 1
}
}
@@ -4,6 +4,7 @@
{
Movies = 1,
Shows = 2,
MusicVideos = 3
MusicVideos = 3,
OtherVideos = 4
}
}
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class OtherVideo : MediaItem
{
public List<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class OtherVideoMetadata : Metadata
{
public int OtherVideoId { get; set; }
public OtherVideo OtherVideo { get; set; }
}
}
+1
View File
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Domain
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public bool InFlood { get; set; }
public bool InDurationFiller { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
+5
View File
@@ -9,12 +9,17 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public DateTime? GuideFinish { get; set; }
public string CustomTitle { get; set; }
public bool CustomGroup { get; set; }
public bool IsFiller { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null;
}
}
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public GuideMode GuideMode { get; set; }
public string CustomTitle { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
@@ -5,6 +5,15 @@ namespace ErsatzTV.Core.Domain
public class ProgramScheduleItemDuration : ProgramScheduleItem
{
public TimeSpan PlayoutDuration { get; set; }
public bool OfflineTail { get; set; }
public TailMode TailMode { get; set; }
public ProgramScheduleItemCollectionType TailCollectionType { get; set; }
public int? TailCollectionId { get; set; }
public Collection TailCollection { get; set; }
public int? TailMediaItemId { get; set; }
public MediaItem TailMediaItem { get; set; }
public int? TailMultiCollectionId { get; set; }
public MultiCollection TailMultiCollection { get; set; }
public int? TailSmartCollectionId { get; set; }
public SmartCollection TailSmartCollection { get; set; }
}
}
+2
View File
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; }
public int Height { get; set; }
public int Width { get; set; }
public override string ToString() => $"{Width}x{Height}";
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public enum TailMode
{
None = 0,
Offline = 1,
Slate = 2,
Filler = 3
}
}
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Emby
foreach (EmbyMovie incoming in validMovies)
{
EmbyMovie incomingMovie = incoming;
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Emby
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyMovie> updated = await _movieRepository.UpdateEmby(incoming);
if (updated.IsSome)
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
foreach (EmbyMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
@@ -326,6 +326,7 @@ namespace ErsatzTV.Core.Emby
{
foreach (EmbyEpisode incoming in episodes)
{
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
@@ -349,12 +350,14 @@ namespace ErsatzTV.Core.Emby
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
catch (Exception ex)
@@ -403,7 +406,7 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
refreshResult.Match(
_ => { },
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelSessionAlreadyActive : BaseError
{
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
{
}
}
}
-4
View File
@@ -7,10 +7,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR" Version="9.0.0" />
+1 -1
View File
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.FFmpeg
{
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel);
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel, string PixelFormat);
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
private string _pixelFormat;
private string _videoEncoder;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -77,6 +78,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
{
_videoEncoder = videoEncoder;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
@@ -118,7 +125,7 @@ namespace ErsatzTV.Core.FFmpeg
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => "deinterlace_qsv",
HardwareAccelerationKind.Nvenc => "", // TODO: yadif_cuda support in docker
HardwareAccelerationKind.Nvenc => "yadif_cuda",
HardwareAccelerationKind.Vaapi => "deinterlace_vaapi",
_ => "yadif=1"
};
@@ -129,16 +136,30 @@ 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(
size =>
{
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
$"hwupload_cuda,scale_cuda={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"
};
@@ -159,7 +180,7 @@ namespace ErsatzTV.Core.FFmpeg
string format = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
_ => "format=nv12"
};
@@ -185,6 +206,10 @@ namespace ErsatzTV.Core.FFmpeg
ChannelWatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
ChannelWatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
ChannelWatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
@@ -213,6 +238,8 @@ namespace ErsatzTV.Core.FFmpeg
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
string outputPixelFormat = null;
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
string.IsNullOrWhiteSpace(watermarkOverlay))
{
@@ -224,6 +251,19 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(upload);
}
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
{
switch (acceleration, _videoEncoder, _pixelFormat)
{
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv420p10le"):
outputPixelFormat = "yuv420p";
break;
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv444p10le"):
outputPixelFormat = "yuv444p";
break;
}
}
bool hasAudioFilters = audioFilterQueue.Any();
if (hasAudioFilters)
{
@@ -233,6 +273,13 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(audioLabel);
}
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{
if (hasAudioFilters)
@@ -279,7 +326,7 @@ namespace ErsatzTV.Core.FFmpeg
var filterResult = complexFilter.ToString();
return string.IsNullOrWhiteSpace(filterResult)
? Option<FFmpegComplexFilter>.None
: new FFmpegComplexFilter(filterResult, videoLabel, audioLabel);
: new FFmpegComplexFilter(filterResult, videoLabel, audioLabel, outputPixelFormat);
}
}
}
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -101,6 +102,22 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder =
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
_ => null
};
}
else
{
@@ -142,14 +159,24 @@ namespace ErsatzTV.Core.FFmpeg
return result;
}
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) =>
new()
public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile)
{
string softwareCodec = ffmpegProfile.VideoCodec switch
{
{ } c when c.Contains("hevc") || c.Contains("265") => "libx265",
{ } c when c.Contains("264") => "libx264",
{ } c when c.Contains("mpeg2") => "mpeg2video",
_ => "libx264"
};
return new FFmpegPlaybackSettings
{
ThreadCount = ffmpegProfile.ThreadCount,
FormatFlags = CommonFormatFlags,
VideoCodec = ffmpegProfile.VideoCodec,
AudioCodec = ffmpegProfile.AudioCodec
VideoCodec = softwareCodec,
AudioCodec = ffmpegProfile.AudioCodec,
};
}
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
+99 -19
View File
@@ -26,39 +26,41 @@ using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
internal class FFmpegProcessBuilder
{
private static readonly Dictionary<string, string> QsvMap = new()
{
{ "h264", "h264_qsv" },
{ "hevc", "hevc_qsv" },
{ "mpeg2video", "mpeg2_qsv" }
};
private readonly List<string> _arguments = new();
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private readonly ILogger _logger;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
private VaapiDriver _vaapiDriver;
private string _vaapiDevice;
private HardwareAccelerationKind _hwAccel;
private string _outputPixelFormat;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{
_ffmpegPath = ffmpegPath;
_saveReports = saveReports;
_logger = logger;
}
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
public FFmpegProcessBuilder WithVaapiDriver(VaapiDriver vaapiDriver, string vaapiDevice)
{
foreach (VaapiDriver vaapiDriver in maybeVaapiDriver)
if (vaapiDriver != VaapiDriver.Default)
{
_vaapiDriver = vaapiDriver;
}
_vaapiDevice = string.IsNullOrWhiteSpace(vaapiDevice)
? "/dev/dri/renderD128"
: vaapiDevice;
return this;
}
@@ -69,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, string pixelFormat, string encoder)
{
_hwAccel = hwAccel;
@@ -82,16 +84,24 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break;
case HardwareAccelerationKind.Nvenc:
string outputFormat = (encoder, pixelFormat) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
// ("hevc_nvenc", "yuv444p10le") => "p016le",
_ => "cuda"
};
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
_arguments.Add("cuda");
_arguments.Add(outputFormat);
break;
case HardwareAccelerationKind.Vaapi:
_arguments.Add("-hwaccel");
_arguments.Add("vaapi");
_arguments.Add("-vaapi_device");
_arguments.Add("/dev/dri/renderD128");
_arguments.Add(_vaapiDevice);
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
@@ -106,7 +116,14 @@ namespace ErsatzTV.Core.FFmpeg
{
if (realtimeOutput)
{
_arguments.Add("-re");
if (!_arguments.Contains("-re"))
{
_arguments.Add("-re");
}
}
else
{
_arguments.RemoveAll(s => s == "-re");
}
return this;
@@ -189,12 +206,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
public FFmpegProcessBuilder WithInputCodec(string input, string decoder, string codec, string pixelFormat)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
if (!string.IsNullOrWhiteSpace(decoder))
{
_arguments.Add("-c:v");
_arguments.Add(qsvCodec);
_arguments.Add(decoder);
}
_complexFilterBuilder = _complexFilterBuilder
@@ -307,15 +324,65 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
{
const int SEGMENT_SECONDS = 4;
var frameRate = 24;
foreach (MediaVersion version in mediaVersion)
{
if (!int.TryParse(version.RFrameRate, out int fr))
{
string[] split = (version.RFrameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
fr = 24;
}
}
frameRate = fr;
}
_arguments.AddRange(
new[]
{
"-use_wallclock_as_timestamps", "1",
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-f", "hls",
"-hls_time", $"{SEGMENT_SECONDS}",
"-hls_list_size", "0",
"-segment_list_flags", "+live",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-flags", "cgop",
"-sc_threshold", "1000000000"
"-sc_threshold", "0" // disable scene change detection
};
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
{
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
}
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
bitrate =>
new[]
@@ -402,8 +469,13 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream,
Option<MediaStream> maybeAudioStream,
string videoCodec)
{
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
@@ -418,6 +490,11 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add(filter.ComplexFilter);
videoLabel = filter.VideoLabel;
audioLabel = filter.AudioLabel;
if (!string.IsNullOrWhiteSpace(filter.PixelFormat))
{
_outputPixelFormat = filter.PixelFormat;
}
});
_arguments.Add("-map");
@@ -457,6 +534,9 @@ namespace ErsatzTV.Core.FFmpeg
case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi");
break;
}
}
+52 -21
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache)
IImageCache imageCache,
ILogger<FFmpegProcessService> logger)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
@@ -35,7 +39,9 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset start,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark,
Option<VaapiDriver> maybeVaapiDriver)
VaapiDriver vaapiDriver,
string vaapiDevice,
bool hlsRealtime)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@@ -56,15 +62,15 @@ namespace ErsatzTV.Core.FFmpeg
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver)
.WithVaapiDriver(vaapiDriver, vaapiDevice)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration, videoStream.PixelFormat, playbackSettings.VideoCodec)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
.WithInputCodec(path, playbackSettings.VideoDecoder, videoStream.Codec, videoStream.PixelFormat)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
@@ -82,7 +88,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
},
() =>
{
@@ -91,37 +97,52 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else
{
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
});
return builder.WithPlaybackArgs(playbackSettings)
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts")
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
.WithDuration(start + version.Duration - now);
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version)
.WithRealtimeOutput(hlsRealtime)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
}
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
public Process ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -132,19 +153,29 @@ namespace ErsatzTV.Core.FFmpeg
.WithErrorText(desiredResolution, errorMessage)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, None)
.WithFormat("mpegts");
.WithMetadata(channel, None);
duration.IfSome(d => builder = builder.WithDuration(d));
return builder.WithPipe().Build();
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, None)
.WithRealtimeOutput(hlsRealtime)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
}
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -0,0 +1,23 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegSegmenterService : IFFmpegSegmenterService
{
public FFmpegSegmenterService()
{
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
}
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
public void TouchChannel(string channelNumber)
{
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
worker?.Touch();
}
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Text;
namespace ErsatzTV.Core.FFmpeg
{
public class HlsPlaylistFilter
{
public static TrimPlaylistResult TrimPlaylist(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines,
int maxSegments = 10,
bool endWithDiscontinuity = false)
{
DateTimeOffset currentTime = playlistStart;
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
var discontinuitySequence = 0;
var startSequence = 0;
var output = new StringBuilder();
var started = false;
var i = 0;
var segments = 0;
while (!lines[i].StartsWith("#EXTINF:"))
{
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
}
i++;
}
while (i < lines.Length)
{
if (segments >= maxSegments)
{
break;
}
string line = lines[i];
// _logger.LogInformation("Line: {Line}", line);
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
{
if (started)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
else
{
discontinuitySequence++;
}
i++;
continue;
}
var duration = TimeSpan.FromSeconds(double.Parse(lines[i].TrimEnd(',').Split(':')[1]));
if (currentTime < filterBefore)
{
currentTime += duration;
i += 3;
continue;
}
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
if (!started)
{
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
output.AppendLine("#EXT-X-DISCONTINUITY");
started = true;
}
output.AppendLine(lines[i]);
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(lines[i + 2]);
currentTime += duration;
segments++;
i += 3;
}
if (endWithDiscontinuity)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
return new TrimPlaylistResult(nextPlaylistStart, startSequence, output.ToString());
}
public static TrimPlaylistResult TrimPlaylistWithDiscontinuity(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines)
{
return TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
}
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist);
}
+2 -1
View File
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.FFmpeg
{
Default = 0,
iHD = 1,
i965 = 2
i965 = 2,
RadeonSI = 3
}
}
+7
View File
@@ -11,6 +11,13 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create),
"ersatztv");
// TODO: find a different spot for this; configurable?
public static readonly string TranscodeFolder = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
@@ -0,0 +1,11 @@
using System.Collections.Concurrent;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
void TouchChannel(string channelNumber);
}
}
@@ -0,0 +1,10 @@
using System;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IHlsSessionWorker
{
DateTimeOffset PlaylistStart { get; }
void Touch();
}
}
@@ -11,6 +11,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
List<EpisodeMetadata> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
string GetSortTitle(string title);
}
}
@@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
}
@@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}
}
@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IOtherVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
}
}
@@ -33,5 +33,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IOtherVideoRepository
{
Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag);
Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids);
// Task<int> GetOtherVideoCount(int artistId);
// Task<List<OtherVideoMetadata>> GetPagedOtherVideos(int artistId, int pageNumber, int pageSize);
}
}
@@ -51,5 +51,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
}
}
+13 -7
View File
@@ -70,13 +70,19 @@ namespace ErsatzTV.Core.Iptv
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
{
var i = 0;
while (i < sorted.Count && sorted[i].IsFiller)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = i;
while (hasCustomTitle && finishIndex + 1 < sorted.Count && sorted[finishIndex + 1].CustomGroup)
while (finishIndex + 1 < sorted.Count && (hasCustomTitle && sorted[finishIndex + 1].CustomGroup ||
sorted[finishIndex + 1].IsFiller))
{
finishIndex++;
}
@@ -97,10 +103,10 @@ namespace ErsatzTV.Core.Iptv
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
// ReSharper disable once StringLiteralTypo
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
// ReSharper disable once StringLiteralTypo
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = startItem.GuideFinishOffset.HasValue
? startItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
@@ -200,10 +206,10 @@ namespace ErsatzTV.Core.Iptv
if (!isSameCustomShow)
{
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
// TODO: multi-episode?
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
if (s > 0 && e > 0)
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
if (s >= 0 && e > 0)
{
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "onscreen");

Some files were not shown because too many files have changed in this diff Show More