Compare commits
33 Commits
v0.1.0-alp
...
v0.1.4-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1802f9d797 | ||
|
|
69354c9296 | ||
|
|
0021e21b50 | ||
|
|
cdf7765059 | ||
|
|
71658c448f | ||
|
|
3ecdd741a5 | ||
|
|
0daeb844b9 | ||
|
|
22da19845b | ||
|
|
3a6d9e9f39 | ||
|
|
7ed4b8ae3c | ||
|
|
be7311e620 | ||
|
|
03be372070 | ||
|
|
d196308ee9 | ||
|
|
3d68b0f055 | ||
|
|
37e32f06ad | ||
|
|
c43ca2837d | ||
|
|
992121f308 | ||
|
|
04adbfeffa | ||
|
|
1fc905c6ad | ||
|
|
4b5dff2159 | ||
|
|
2a5edf8214 | ||
|
|
69912c8cae | ||
|
|
fd3de2d82a | ||
|
|
6ba9404752 | ||
|
|
db080375c5 | ||
|
|
9abc7ad8b7 | ||
|
|
9e531a82d7 | ||
|
|
d84bd2b948 | ||
|
|
d7d3ec1235 | ||
|
|
742ac21ad7 | ||
|
|
819b55e21f | ||
|
|
cf5718c288 | ||
|
|
adc7982955 |
60
CHANGELOG.md
60
CHANGELOG.md
@@ -4,6 +4,60 @@ 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
|
||||
@@ -660,7 +714,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
17
ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs
Normal file
17
ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs
Normal file
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public record CleanUpFFmpegSessions : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public class CleanUpFFmpegSessionsHandler : IRequestHandler<CleanUpFFmpegSessions, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
|
||||
|
||||
public CleanUpFFmpegSessionsHandler(ChannelWriter<IFFmpegWorkerRequest> channel)
|
||||
{
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(CleanUpFFmpegSessions request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _channel.WriteAsync(request, cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
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;
|
||||
|
||||
@@ -15,21 +17,24 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IFFmpegWorkerRequest> channel,
|
||||
ILogger<StartFFmpegSessionHandler> logger)
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -42,24 +47,55 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession request)
|
||||
{
|
||||
await _channel.WriteAsync(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);
|
||||
|
||||
// TODO: find some other way to let ffmpeg get ahead
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
// 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) =>
|
||||
ProcessMustNotExist(request)
|
||||
SessionMustBeInactive(request)
|
||||
.BindT(_ => FolderMustBeEmpty(request));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
|
||||
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
|
||||
.Filter(exists => exists == false)
|
||||
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 ChannelHasProcess())
|
||||
.AsTask();
|
||||
.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)
|
||||
{
|
||||
|
||||
243
ErsatzTV.Application/Streaming/HlsSessionWorker.cs
Normal file
243
ErsatzTV.Application/Streaming/HlsSessionWorker.cs
Normal file
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -6,5 +6,10 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record FFmpegProcessRequest
|
||||
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>;
|
||||
(
|
||||
string ChannelNumber,
|
||||
string Mode,
|
||||
DateTimeOffset Now,
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||
}
|
||||
|
||||
@@ -1,11 +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",
|
||||
false)
|
||||
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,11 +1,20 @@
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base(
|
||||
public GetPlayoutItemProcessByChannelNumber(
|
||||
string channelNumber,
|
||||
string mode,
|
||||
DateTimeOffset now,
|
||||
bool startAtZero,
|
||||
bool hlsRealtime) : base(
|
||||
channelNumber,
|
||||
mode,
|
||||
startAtZero)
|
||||
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 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,
|
||||
request.StartAtZero ? 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,
|
||||
|
||||
223
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
Normal file
223
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
Normal file
@@ -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
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
p1.Start();
|
||||
await p1.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
p1.WaitForExit();
|
||||
p1.ExitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
@@ -184,15 +186,19 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
None);
|
||||
VaapiDriver.Default,
|
||||
"/dev/dri/renderD128",
|
||||
false);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
process.Start().Should().BeTrue();
|
||||
|
||||
await process.StandardOutput.ReadToEndAsync();
|
||||
process.BeginOutputReadLine();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
process.WaitForExit();
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
@@ -203,9 +209,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
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");
|
||||
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
{
|
||||
@@ -214,7 +220,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
else
|
||||
{
|
||||
process.ExitCode.Should().Be(0, error);
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
process.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
ErsatzTV.Core/Domain/GuideMode.cs
Normal file
8
ErsatzTV.Core/Domain/GuideMode.cs
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
10
ErsatzTV.Core/Domain/MediaItem/OtherVideo.cs
Normal file
10
ErsatzTV.Core/Domain/MediaItem/OtherVideo.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
8
ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs
Normal file
8
ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class OtherVideoMetadata : Metadata
|
||||
{
|
||||
public int OtherVideoId { get; set; }
|
||||
public OtherVideo OtherVideo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
10
ErsatzTV.Core/Domain/TailMode.cs
Normal file
10
ErsatzTV.Core/Domain/TailMode.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum TailMode
|
||||
{
|
||||
None = 0,
|
||||
Offline = 1,
|
||||
Slate = 2,
|
||||
Filler = 3
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelHasProcess : BaseError
|
||||
{
|
||||
public ChannelHasProcess() : base("Channel already has ffmpeg process")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
9
ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs
Normal file
9
ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelSessionAlreadyActive : BaseError
|
||||
{
|
||||
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -125,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"
|
||||
};
|
||||
@@ -156,8 +156,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string filter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
|
||||
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
@@ -180,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"
|
||||
};
|
||||
@@ -206,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}"
|
||||
};
|
||||
|
||||
@@ -234,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))
|
||||
{
|
||||
@@ -245,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)
|
||||
{
|
||||
@@ -307,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,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
|
||||
{
|
||||
@@ -143,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 = "libx264",
|
||||
VideoCodec = softwareCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
|
||||
|
||||
@@ -32,13 +32,6 @@ 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;
|
||||
@@ -46,7 +39,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
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, ILogger logger)
|
||||
{
|
||||
@@ -55,13 +50,17 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -72,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;
|
||||
|
||||
@@ -85,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;
|
||||
@@ -109,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;
|
||||
@@ -192,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
|
||||
@@ -310,34 +324,45 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
|
||||
{
|
||||
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
|
||||
var frameRate = 24;
|
||||
|
||||
foreach (MediaVersion version in mediaVersion)
|
||||
{
|
||||
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
if (!int.TryParse(version.RFrameRate, out int fr))
|
||||
{
|
||||
frameRate = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
|
||||
frameRate = 24;
|
||||
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[]
|
||||
{
|
||||
"-g", $"{frameRate * 2}",
|
||||
"-keyint_min", $"{frameRate * 2}",
|
||||
// "-force_key_frames",
|
||||
// "expr:gte(t,n_forced*2)",
|
||||
"-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", "2",
|
||||
"-hls_list_size", "10",
|
||||
"-hls_time", $"{SEGMENT_SECONDS}",
|
||||
"-hls_list_size", "0",
|
||||
"-segment_list_flags", "+live",
|
||||
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
|
||||
"-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")
|
||||
});
|
||||
|
||||
@@ -353,6 +378,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
"-sc_threshold", "0" // disable scene change detection
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
|
||||
{
|
||||
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
|
||||
}
|
||||
|
||||
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
@@ -460,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");
|
||||
@@ -499,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,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);
|
||||
@@ -62,13 +64,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
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)
|
||||
@@ -119,6 +121,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
return builder.WithFormat("mpegts")
|
||||
@@ -127,7 +130,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -145,12 +153,22 @@ 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)
|
||||
|
||||
@@ -1,96 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegSegmenterService : IFFmpegSegmenterService
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ProcessAndToken> Processes = new();
|
||||
|
||||
private readonly ILogger<FFmpegSegmenterService> _logger;
|
||||
|
||||
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) => _logger = logger;
|
||||
|
||||
public bool ProcessExistsForChannel(string channelNumber)
|
||||
public FFmpegSegmenterService()
|
||||
{
|
||||
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
|
||||
{
|
||||
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
|
||||
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
|
||||
}
|
||||
|
||||
public bool TryAdd(string channelNumber, Process process)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
var processAndToken = new ProcessAndToken(process, cts, DateTimeOffset.Now);
|
||||
if (Processes.TryAdd(channelNumber, processAndToken))
|
||||
{
|
||||
CancellationToken token = cts.Token;
|
||||
token.Register(process.Kill);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
public void TouchChannel(string channelNumber)
|
||||
{
|
||||
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
|
||||
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
|
||||
{
|
||||
ProcessAndToken newValue = processAndToken with { LastAccess = DateTimeOffset.Now };
|
||||
if (!Processes.TryUpdate(channelNumber, newValue, processAndToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to update last access for channel {Channel}", channelNumber);
|
||||
}
|
||||
worker?.Touch();
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanUpSessions()
|
||||
{
|
||||
foreach ((string key, (_, CancellationTokenSource cts, DateTimeOffset lastAccess)) in Processes.ToList())
|
||||
{
|
||||
// TODO: configure this time span? 5 min?
|
||||
if (DateTimeOffset.Now.Subtract(lastAccess) > TimeSpan.FromMinutes(2))
|
||||
{
|
||||
_logger.LogDebug("Cleaning up ffmpeg session for channel {Channel}", key);
|
||||
|
||||
cts.Cancel();
|
||||
Processes.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Unit KillAll()
|
||||
{
|
||||
foreach ((string key, ProcessAndToken processAndToken) in Processes.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
processAndToken.TokenSource.Cancel();
|
||||
Processes.TryRemove(key, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Error killing process");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record ProcessAndToken(Process Process, CancellationTokenSource TokenSource, DateTimeOffset LastAccess);
|
||||
}
|
||||
}
|
||||
|
||||
111
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
Normal file
111
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
Normal 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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2
|
||||
i965 = 2,
|
||||
RadeonSI = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using LanguageExt;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegSegmenterService
|
||||
{
|
||||
bool ProcessExistsForChannel(string channelNumber);
|
||||
bool TryAdd(string channelNumber, Process process);
|
||||
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
void TouchChannel(string channelNumber);
|
||||
void CleanUpSessions();
|
||||
Unit KillAll();
|
||||
}
|
||||
}
|
||||
|
||||
10
ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs
Normal file
10
ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -88,6 +89,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
return GetMusicVideoMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
public Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo)
|
||||
{
|
||||
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileNameWithoutExtension(path);
|
||||
var metadata = new OtherVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path,
|
||||
OtherVideo = otherVideo
|
||||
};
|
||||
|
||||
return GetOtherVideoMetadata(path, metadata);
|
||||
}
|
||||
|
||||
public string GetSortTitle(string title)
|
||||
{
|
||||
@@ -214,6 +229,43 @@ namespace ErsatzTV.Core.Metadata
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
string folder = Path.GetDirectoryName(path);
|
||||
if (folder == null)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
string libraryPath = metadata.OtherVideo.LibraryPath.Path;
|
||||
string parent = Optional(Directory.GetParent(libraryPath)).Match(
|
||||
di => di.FullName,
|
||||
() => libraryPath);
|
||||
|
||||
string diff = Path.GetRelativePath(parent, folder);
|
||||
|
||||
var tags = diff.Split(Path.DirectorySeparatorChar)
|
||||
.Map(t => new Tag { Name = t })
|
||||
.ToList();
|
||||
|
||||
metadata.Artwork = new List<Artwork>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = tags;
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
|
||||
@@ -73,6 +73,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
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))
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(
|
||||
@@ -38,6 +39,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IEpisodeNfoReader episodeNfoReader,
|
||||
@@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_episodeNfoReader = episodeNfoReader;
|
||||
@@ -136,6 +139,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
|
||||
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(otherVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(otherVideo, metadata),
|
||||
() => Task.FromResult(false));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
|
||||
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
@@ -614,6 +622,45 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private Task<bool> ApplyMetadataUpdate(OtherVideo otherVideo, OtherVideoMetadata metadata) =>
|
||||
Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.Title = metadata.Title;
|
||||
|
||||
if (existing.DateAdded == SystemTime.MinValueUtc)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
|
||||
bool updated = await UpdateMetadataCollections(
|
||||
existing,
|
||||
metadata,
|
||||
(_, _) => Task.FromResult(false),
|
||||
_otherVideoRepository.AddTag,
|
||||
(_, _) => Task.FromResult(false),
|
||||
(_, _) => Task.FromResult(false));
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.OtherVideoId = otherVideo.Id;
|
||||
otherVideo.OtherVideoMetadata = new List<OtherVideoMetadata> { metadata };
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
OtherVideo ov => ov.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
@@ -82,6 +83,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
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))
|
||||
};
|
||||
|
||||
|
||||
197
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
Normal file
197
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<OtherVideoFolderScanner> _logger;
|
||||
|
||||
public OtherVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IOtherVideoRepository otherVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
ILogger<OtherVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
string otherVideoFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == otherVideoFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
otherVideoFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
_logger.LogDebug("Other video found at {File}", file);
|
||||
|
||||
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffprobePath))
|
||||
.BindT(UpdateMetadata);
|
||||
|
||||
await maybeVideo.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Removing missing other video at {Path}", path);
|
||||
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(otherVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(otherVideoIds);
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateMetadata(
|
||||
MediaItemScanResult<OtherVideo> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
OtherVideo otherVideo = result.Item;
|
||||
if (!Optional(otherVideo.OtherVideoMetadata).Flatten().Any())
|
||||
{
|
||||
otherVideo.OtherVideoMetadata ??= new List<OtherVideoMetadata>();
|
||||
|
||||
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(otherVideo))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
bool rebuild = false)
|
||||
{
|
||||
var collectionKeys = playout.ProgramSchedule.Items
|
||||
.Map(CollectionKeyForItem)
|
||||
.SelectMany(CollectionKeysForItem)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
@@ -118,14 +118,14 @@ namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
bool isZero = item switch
|
||||
{
|
||||
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
_ => true
|
||||
};
|
||||
|
||||
@@ -160,12 +160,14 @@ namespace ErsatzTV.Core.Scheduling
|
||||
c => c.Value.Any(
|
||||
mi => mi switch
|
||||
{
|
||||
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration).IfNone(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
|
||||
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
|
||||
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration)
|
||||
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration)
|
||||
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
|
||||
_ => true
|
||||
})).Map(c => c.Key);
|
||||
if (zeroDurationCollection.IsSome)
|
||||
@@ -192,9 +194,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
|
||||
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
|
||||
{
|
||||
PlaybackOrder playbackOrder = sortedScheduleItems
|
||||
.First(item => CollectionKeyForItem(item) == collectionKey)
|
||||
.PlaybackOrder;
|
||||
// use configured playback order for primary collection, shuffle for filler
|
||||
Option<ProgramScheduleItem> maybeScheduleItem = sortedScheduleItems
|
||||
.FirstOrDefault(item => CollectionKeyForItem(item) == collectionKey);
|
||||
PlaybackOrder playbackOrder = maybeScheduleItem
|
||||
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
@@ -211,6 +215,15 @@ namespace ErsatzTV.Core.Scheduling
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name,
|
||||
currentTime);
|
||||
|
||||
// removing any items scheduled past the start anchor
|
||||
// this could happen if the app was closed after scheduling items
|
||||
// but before saving the anchor
|
||||
int removed = playout.Items.RemoveAll(pi => pi.StartOffset >= currentTime);
|
||||
if (removed > 0)
|
||||
{
|
||||
_logger.LogWarning("Removed {Count} schedule items beyond current start anchor", removed);
|
||||
}
|
||||
|
||||
// start with the previously-decided schedule item
|
||||
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
|
||||
@@ -219,6 +232,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
|
||||
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
|
||||
bool inFlood = startAnchor.InFlood;
|
||||
bool inDurationFiller = startAnchor.InDurationFiller;
|
||||
|
||||
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
|
||||
|
||||
@@ -234,16 +248,33 @@ namespace ErsatzTV.Core.Scheduling
|
||||
currentTime,
|
||||
multipleRemaining.IsSome,
|
||||
durationFinish.IsSome,
|
||||
inFlood);
|
||||
inFlood,
|
||||
inDurationFiller);
|
||||
|
||||
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None;
|
||||
if (inDurationFiller && scheduleItem is ProgramScheduleItemDuration
|
||||
{
|
||||
TailMode: TailMode.Filler
|
||||
})
|
||||
{
|
||||
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem);
|
||||
}
|
||||
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
|
||||
foreach (CollectionKey tailCollectionKey in maybeTailCollectionKey)
|
||||
{
|
||||
enumerator = collectionEnumerators[tailCollectionKey];
|
||||
}
|
||||
|
||||
await enumerator.Current.IfSomeAsync(
|
||||
mediaItem =>
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}",
|
||||
scheduleItem.Index,
|
||||
scheduleItem.CollectionType,
|
||||
inDurationFiller
|
||||
? (scheduleItem as ProgramScheduleItemDuration)?.TailCollectionType
|
||||
: scheduleItem.CollectionType,
|
||||
mediaItem.Id,
|
||||
DisplayTitle(mediaItem),
|
||||
itemStartTime);
|
||||
@@ -253,6 +284,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo mv => mv.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
@@ -261,7 +293,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
MediaItemId = mediaItem.Id,
|
||||
Start = itemStartTime.UtcDateTime,
|
||||
Finish = itemStartTime.UtcDateTime + version.Duration,
|
||||
CustomGroup = customGroup
|
||||
CustomGroup = customGroup,
|
||||
IsFiller = inDurationFiller || scheduleItem.GuideMode == GuideMode.Filler
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(scheduleItem.CustomTitle))
|
||||
@@ -323,6 +356,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
|
||||
};
|
||||
|
||||
@@ -363,6 +397,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
|
||||
};
|
||||
|
||||
@@ -383,14 +418,34 @@ namespace ErsatzTV.Core.Scheduling
|
||||
"Advancing to next schedule item after playout mode {PlayoutMode}",
|
||||
"Duration");
|
||||
index++;
|
||||
customGroup = false;
|
||||
|
||||
if (duration.OfflineTail)
|
||||
if (duration.TailMode == TailMode.Offline)
|
||||
{
|
||||
durationFinish.Do(f => currentTime = f);
|
||||
}
|
||||
|
||||
durationFinish = None;
|
||||
if (duration.TailMode != TailMode.Filler || inDurationFiller)
|
||||
{
|
||||
if (duration.TailMode != TailMode.None)
|
||||
{
|
||||
durationFinish.Do(f => currentTime = f);
|
||||
}
|
||||
|
||||
durationFinish = None;
|
||||
inDurationFiller = false;
|
||||
customGroup = false;
|
||||
}
|
||||
else if (duration.TailMode == TailMode.Filler &&
|
||||
WillFinishFillerInTime(
|
||||
scheduleItem,
|
||||
currentTime,
|
||||
durationFinish,
|
||||
collectionEnumerators))
|
||||
{
|
||||
inDurationFiller = true;
|
||||
durationFinish.Do(
|
||||
f => playoutItem.GuideFinish = f.UtcDateTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -406,7 +461,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
|
||||
|
||||
// remove any items outside the desired range
|
||||
playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish);
|
||||
playout.Items.RemoveAll(
|
||||
old => old.FinishOffset < playoutStart.AddHours(-4) || old.StartOffset > playoutFinish);
|
||||
|
||||
DateTimeOffset minCurrentTime = currentTime;
|
||||
if (playout.Items.Any())
|
||||
@@ -425,12 +481,62 @@ namespace ErsatzTV.Core.Scheduling
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, minCurrentTime).UtcDateTime,
|
||||
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
|
||||
InFlood = inFlood
|
||||
InFlood = inFlood,
|
||||
InDurationFiller = inDurationFiller
|
||||
};
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
private static bool WillFinishFillerInTime(
|
||||
ProgramScheduleItem scheduleItem,
|
||||
DateTimeOffset currentTime,
|
||||
Option<DateTimeOffset> durationFinish,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
|
||||
{
|
||||
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None;
|
||||
if (scheduleItem is ProgramScheduleItemDuration
|
||||
{
|
||||
TailMode: TailMode.Filler
|
||||
})
|
||||
{
|
||||
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem);
|
||||
}
|
||||
|
||||
foreach (CollectionKey collectionKey in maybeTailCollectionKey)
|
||||
{
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[collectionKey];
|
||||
Option<int> firstId = enumerator.Current.Map(i => i.Id);
|
||||
while (true)
|
||||
{
|
||||
foreach (MediaItem peekMediaItem in enumerator.Current)
|
||||
{
|
||||
MediaVersion peekVersion = peekMediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
|
||||
};
|
||||
|
||||
if (currentTime + peekVersion.Duration <= durationFinish.IfNone(SystemTime.MinValueUtc))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
enumerator.MoveNext();
|
||||
if (enumerator.Current.Map(i => i.Id) == firstId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PlayoutAnchor FindStartAnchor(
|
||||
Playout playout,
|
||||
DateTimeOffset start,
|
||||
@@ -465,14 +571,16 @@ namespace ErsatzTV.Core.Scheduling
|
||||
DateTimeOffset start,
|
||||
bool inMultiple = false,
|
||||
bool inDuration = false,
|
||||
bool inFlood = false)
|
||||
bool inFlood = false,
|
||||
bool inDurationFiller = false)
|
||||
{
|
||||
switch (item.StartType)
|
||||
{
|
||||
case StartType.Fixed:
|
||||
if (item is ProgramScheduleItemMultiple && inMultiple ||
|
||||
item is ProgramScheduleItemDuration && inDuration ||
|
||||
item is ProgramScheduleItemFlood && inFlood)
|
||||
item is ProgramScheduleItemFlood && inFlood ||
|
||||
item is ProgramScheduleItemDuration && inDurationFiller)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
@@ -641,11 +749,22 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return mv.MusicVideoMetadata.HeadOrNone()
|
||||
.Map(mvm => $"{artistName}{mvm.Title}")
|
||||
.IfNone("[unknown music video]");
|
||||
case OtherVideo ov:
|
||||
return ov.OtherVideoMetadata.HeadOrNone().Match(
|
||||
ovm => ovm.Title ?? string.Empty,
|
||||
() => "[unknown video]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CollectionKey> CollectionKeysForItem(ProgramScheduleItem item)
|
||||
{
|
||||
var result = new List<CollectionKey> { CollectionKeyForItem(item) };
|
||||
result.AddRange(TailCollectionKeyForItem(item));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
|
||||
item.CollectionType switch
|
||||
{
|
||||
@@ -682,6 +801,49 @@ namespace ErsatzTV.Core.Scheduling
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(item))
|
||||
};
|
||||
|
||||
private static Option<CollectionKey> TailCollectionKeyForItem(ProgramScheduleItem item)
|
||||
{
|
||||
if (item is ProgramScheduleItemDuration { TailMode: TailMode.Filler } duration)
|
||||
{
|
||||
return duration.TailCollectionType switch
|
||||
{
|
||||
ProgramScheduleItemCollectionType.Collection => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
CollectionId = duration.TailCollectionId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
MediaItemId = duration.TailMediaItemId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
MediaItemId = duration.TailMediaItemId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.Artist => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
MediaItemId = duration.TailMediaItemId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
MultiCollectionId = duration.TailMultiCollectionId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey
|
||||
{
|
||||
CollectionType = duration.TailCollectionType,
|
||||
SmartCollectionId = duration.TailSmartCollectionId
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(item))
|
||||
};
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private class CollectionKey : Record<CollectionKey>
|
||||
{
|
||||
public ProgramScheduleItemCollectionType CollectionType { get; set; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class OtherVideoConfiguration : IEntityTypeConfiguration<OtherVideo>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OtherVideo> builder)
|
||||
{
|
||||
builder.ToTable("OtherVideo");
|
||||
|
||||
builder.HasMany(m => m.OtherVideoMetadata)
|
||||
.WithOne(m => m.OtherVideo)
|
||||
.HasForeignKey(m => m.OtherVideoId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(m => m.MediaVersions)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVideoMetadata>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OtherVideoMetadata> builder)
|
||||
{
|
||||
builder.ToTable("OtherVideoMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,27 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class ProgramScheduleItemDurationConfiguration : IEntityTypeConfiguration<ProgramScheduleItemDuration>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProgramScheduleItemDuration> builder) =>
|
||||
public void Configure(EntityTypeBuilder<ProgramScheduleItemDuration> builder)
|
||||
{
|
||||
builder.ToTable("ProgramScheduleDurationItem");
|
||||
|
||||
builder.HasOne(i => i.TailCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TailCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.TailMediaItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TailMediaItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.TailMultiCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TailMultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
@@ -79,6 +80,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
|
||||
}
|
||||
|
||||
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
|
||||
@@ -137,6 +139,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
|
||||
|
||||
var otherVideoIds = searchResults.Items
|
||||
.Filter(i => i.Type == SearchIndex.OtherVideoType)
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -410,6 +418,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.MediaVersions)
|
||||
.Filter(m => musicVideoIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT o.Id FROM CollectionItem ci
|
||||
INNER JOIN OtherVideo o ON o.Id = ci.MediaItemId
|
||||
WHERE ci.CollectionId = @CollectionId",
|
||||
new { CollectionId = collectionId });
|
||||
|
||||
return await GetOtherVideoItems(dbContext, ids);
|
||||
}
|
||||
|
||||
private static Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, IEnumerable<int> otherVideoIds) =>
|
||||
dbContext.OtherVideos
|
||||
.Include(m => m.OtherVideoMetadata)
|
||||
.Include(m => m.MediaVersions)
|
||||
.Filter(m => otherVideoIds.Contains(m.Id))
|
||||
.ToListAsync();
|
||||
|
||||
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class OtherVideoRepository : IOtherVideoRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public OtherVideoRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(
|
||||
LibraryPath libraryPath,
|
||||
string path)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<OtherVideo> maybeExisting = await dbContext.OtherVideos
|
||||
.AsNoTracking()
|
||||
.Include(ov => ov.OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(ov => ov.OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Tags)
|
||||
.Include(ov => ov.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(ov => ov.MediaFiles)
|
||||
.Include(ov => ov.MediaVersions)
|
||||
.ThenInclude(ov => ov.Streams)
|
||||
.Include(ov => ov.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<OtherVideo>>(
|
||||
new MediaItemScanResult<OtherVideo>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddOtherVideo(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT MF.Path
|
||||
FROM MediaFile MF
|
||||
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
|
||||
INNER JOIN OtherVideo O on MV.OtherVideoId = O.Id
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
WHERE MI.LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPath.Id });
|
||||
|
||||
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT O.Id
|
||||
FROM OtherVideo O
|
||||
INNER JOIN MediaItem MI on O.Id = MI.Id
|
||||
INNER JOIN MediaVersion MV on O.Id = MV.OtherVideoId
|
||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
foreach (int otherVideoId in ids)
|
||||
{
|
||||
OtherVideo othervide = await dbContext.OtherVideos.FindAsync(otherVideoId);
|
||||
dbContext.OtherVideos.Remove(othervide);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Tag (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public async Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => ids.Contains(ovm.OtherVideoId))
|
||||
.Include(ovm => ovm.OtherVideo)
|
||||
.Include(ovm => ovm.Artwork)
|
||||
.OrderBy(ovm => ovm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> AddOtherVideo(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var otherVideo = new OtherVideo
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
},
|
||||
TraktListItems = new List<TraktListItem>()
|
||||
};
|
||||
|
||||
await dbContext.OtherVideos.AddAsync(otherVideo);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(otherVideo).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(otherVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<OtherVideo>(otherVideo) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Styles)
|
||||
.Include(mi => (mi as Artist).ArtistMetadata)
|
||||
.ThenInclude(mm => mm.Moods)
|
||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(mi => mi.Id)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user