Compare commits
24
Commits
| 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 |
+49
-1
@@ -4,6 +4,51 @@ 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
|
||||
@@ -669,7 +714,10 @@ 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.1-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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -103,6 +103,13 @@ namespace ErsatzTV.Application.MediaCards
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record OtherVideoCardResultsViewModel(
|
||||
int Count,
|
||||
List<OtherVideoCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record OtherVideoCardViewModel
|
||||
(
|
||||
int OtherVideoId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle) : MediaCardViewModel(
|
||||
OtherVideoId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
null)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.ToList();
|
||||
|
||||
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record AddOtherVideoToCollection
|
||||
(int CollectionId, int OtherVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class AddOtherVideoToCollectionHandler :
|
||||
MediatR.IRequestHandler<AddOtherVideoToCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public AddOtherVideoToCollectionHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddOtherVideoToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(parameters.Collection.Id))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddOtherVideoToCollection request) =>
|
||||
(await CollectionMustExist(dbContext, request), await ValidateOtherVideo(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
AddOtherVideoToCollection request) =>
|
||||
dbContext.Collections
|
||||
.Include(c => c.MediaItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, OtherVideo>> ValidateOtherVideo(
|
||||
TvContext dbContext,
|
||||
AddOtherVideoToCollection request) =>
|
||||
dbContext.OtherVideos
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.OtherVideoId)
|
||||
.Map(o => o.ToValidation<BaseError>("OtherVideo does not exist"));
|
||||
|
||||
private record Parameters(Collection Collection, OtherVideo OtherVideo);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
IMovieFolderScanner movieFolderScanner,
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IOtherVideoFolderScanner otherVideoFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
@@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
_movieFolderScanner = movieFolderScanner;
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_otherVideoFolderScanner = otherVideoFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
@@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.OtherVideos:
|
||||
await _otherVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
+12
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -18,18 +20,21 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -42,12 +47,16 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession request)
|
||||
{
|
||||
TimeSpan idleTimeout = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout)
|
||||
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
|
||||
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
|
||||
|
||||
// fire and forget worker
|
||||
_ = worker.Run(request.ChannelNumber)
|
||||
_ = worker.Run(request.ChannelNumber, idleTimeout)
|
||||
.ContinueWith(
|
||||
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
|
||||
@@ -6,23 +6,27 @@ 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 readonly Timer _timer = new(TimeSpan.FromMinutes(2).TotalMilliseconds) { AutoReset = false };
|
||||
private Timer _timer;
|
||||
private readonly object _sync = new();
|
||||
private DateTimeOffset _playlistStart;
|
||||
|
||||
@@ -40,19 +44,23 @@ namespace ErsatzTV.Application.Streaming
|
||||
{
|
||||
_lastAccess = DateTimeOffset.Now;
|
||||
|
||||
_timer.Stop();
|
||||
_timer.Start();
|
||||
_timer?.Stop();
|
||||
_timer?.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber)
|
||||
public async Task Run(string channelNumber, TimeSpan idleTimeout)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
_timer.Elapsed += Cancel;
|
||||
lock (_sync)
|
||||
{
|
||||
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
|
||||
_timer.Elapsed += Cancel;
|
||||
}
|
||||
|
||||
CancellationToken cancellationToken = cts.Token;
|
||||
|
||||
@@ -62,16 +70,15 @@ namespace ErsatzTV.Application.Streaming
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
_playlistStart = _transcodedUntil;
|
||||
|
||||
// start initial transcode WITHOUT realtime throttle
|
||||
if (!await Transcode(channelNumber, true, false, cancellationToken))
|
||||
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
|
||||
if (!await Transcode(channelNumber, true, !initialWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// TODO: configurable? 5 minutes?
|
||||
if (DateTimeOffset.Now - _lastAccess > TimeSpan.FromMinutes(2))
|
||||
if (DateTimeOffset.Now - _lastAccess > idleTimeout)
|
||||
{
|
||||
_logger.LogInformation("Stopping idle HLS session for channel {Channel}", channelNumber);
|
||||
return;
|
||||
@@ -83,7 +90,9 @@ namespace ErsatzTV.Application.Streaming
|
||||
{
|
||||
// only use realtime encoding when we're at least 30 seconds ahead
|
||||
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
|
||||
if (!await Transcode(channelNumber, false, realtime, cancellationToken))
|
||||
bool subsequentWorkAhead =
|
||||
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
|
||||
if (!await Transcode(channelNumber, false, !subsequentWorkAhead, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +106,10 @@ namespace ErsatzTV.Application.Streaming
|
||||
}
|
||||
finally
|
||||
{
|
||||
_timer.Elapsed -= Cancel;
|
||||
lock (_sync)
|
||||
{
|
||||
_timer.Elapsed -= Cancel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +117,18 @@ namespace ErsatzTV.Application.Streaming
|
||||
{
|
||||
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>();
|
||||
|
||||
@@ -134,7 +158,7 @@ namespace ErsatzTV.Application.Streaming
|
||||
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
|
||||
{
|
||||
await TrimAndDelete(channelNumber, cancellationToken);
|
||||
|
||||
|
||||
Process process = processModel.Process;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -166,6 +190,10 @@ namespace ErsatzTV.Application.Streaming
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -203,5 +231,13 @@ namespace ErsatzTV.Application.Streaming
|
||||
_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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -75,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);
|
||||
@@ -87,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))
|
||||
};
|
||||
|
||||
@@ -100,10 +107,6 @@ 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,
|
||||
@@ -113,8 +116,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver,
|
||||
request.StartAtZero,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
request.HlsRealtime);
|
||||
|
||||
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
|
||||
@@ -149,7 +152,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime);
|
||||
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
@@ -168,7 +172,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
error.Value);
|
||||
error.Value,
|
||||
request.HlsRealtime);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
@@ -186,7 +191,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
@@ -220,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))
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,17 +186,19 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
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 =
|
||||
{
|
||||
@@ -205,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"))
|
||||
{
|
||||
@@ -216,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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class OtherVideo : MediaItem
|
||||
{
|
||||
public List<OtherVideoMetadata> OtherVideoMetadata { get; set; }
|
||||
public List<MediaVersion> MediaVersions { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class OtherVideoMetadata : Metadata
|
||||
{
|
||||
public int OtherVideoId { get; set; }
|
||||
public OtherVideo OtherVideo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum TailMode
|
||||
{
|
||||
None = 0,
|
||||
Offline = 1,
|
||||
Slate = 2,
|
||||
Filler = 3
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -199,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
|
||||
@@ -317,22 +324,29 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero)
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
|
||||
{
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
|
||||
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
|
||||
|
||||
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(
|
||||
@@ -364,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[]
|
||||
@@ -471,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");
|
||||
@@ -510,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,8 +39,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
Option<VaapiDriver> maybeVaapiDriver,
|
||||
bool startAtZero,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
bool hlsRealtime)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
@@ -64,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)
|
||||
@@ -120,7 +120,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version, startAtZero)
|
||||
return builder.WithHls(channel.Number, version)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
@@ -130,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);
|
||||
@@ -148,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)
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2
|
||||
i965 = 2,
|
||||
RadeonSI = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -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)
|
||||
|
||||
@@ -39,6 +39,8 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
|
||||
public DbSet<MusicVideo> MusicVideos { get; set; }
|
||||
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
|
||||
public DbSet<OtherVideo> OtherVideos { get; set; }
|
||||
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
|
||||
public DbSet<Show> Shows { get; set; }
|
||||
public DbSet<ShowMetadata> ShowMetadata { get; set; }
|
||||
public DbSet<Season> Seasons { get; set; }
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.11" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
|
||||
{
|
||||
private const string BundledVersion = "N-104321-ga742ba60f1";
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
|
||||
@@ -72,17 +73,17 @@ namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
if (version.StartsWith("3."))
|
||||
{
|
||||
return FailResult($"{app} version {version} is too old; please install 4.3!");
|
||||
return FailResult($"{app} version {version} is too old; please install 4.4!");
|
||||
}
|
||||
|
||||
if (version.StartsWith("4.4"))
|
||||
if (version.StartsWith("4.3"))
|
||||
{
|
||||
return FailResult($"{app} version 4.4 is known to have issues; please install 4.3!");
|
||||
return WarningResult($"{app} version 4.4 is now supported, please upgrade from 4.3!");
|
||||
}
|
||||
|
||||
if (!version.StartsWith("4.3"))
|
||||
if (!version.StartsWith("4.4") && version != BundledVersion)
|
||||
{
|
||||
return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.3!");
|
||||
return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.4!");
|
||||
}
|
||||
|
||||
return None;
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Health;
|
||||
using ErsatzTV.Core.Health.Checks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Health.Checks
|
||||
{
|
||||
public class VaapiDriverHealthCheck : BaseHealthCheck, IVaapiDriverHealthCheck
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public VaapiDriverHealthCheck(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
public VaapiDriverHealthCheck(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
protected override string Title => "VAAPI Driver";
|
||||
|
||||
public async Task<HealthCheckResult> Check()
|
||||
{
|
||||
string version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion ?? "unknown";
|
||||
|
||||
if (!version.Contains("docker", StringComparison.OrdinalIgnoreCase) ||
|
||||
!version.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<FFmpegProfile> profiles = await dbContext.FFmpegProfiles
|
||||
.Filter(p => p.HardwareAcceleration == HardwareAccelerationKind.Vaapi)
|
||||
.ToListAsync();
|
||||
|
||||
if (profiles.Count == 0)
|
||||
{
|
||||
return NotApplicableResult();
|
||||
}
|
||||
|
||||
Option<int> maybeVaapiDriver =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
|
||||
var vaapiDriver = (VaapiDriver)await maybeVaapiDriver.IfNoneAsync(0);
|
||||
if (vaapiDriver == VaapiDriver.Default)
|
||||
{
|
||||
return InfoResult(
|
||||
"Settings > FFmpeg Settings > VAAPI Driver is set to Default; selecting iHD (Gen 8+) or i965 (up to Gen 9) may offer better performance");
|
||||
}
|
||||
|
||||
return OkResult();
|
||||
|
||||
var defaultProfiles = profiles
|
||||
.Filter(p => p.VaapiDriver == VaapiDriver.Default)
|
||||
.ToList();
|
||||
|
||||
return defaultProfiles.Any()
|
||||
? InfoResult(
|
||||
$"{defaultProfiles.Count} FFmpeg Profile{(defaultProfiles.Count > 1 ? "s are" : " is")} set to use Default VAAPI Driver; selecting iHD (Gen 8+) or i965 (up to Gen 9) may offer better performance with Intel iGPU")
|
||||
: OkResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3293
File diff suppressed because it is too large
Load Diff
+34
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_FFmpegProfileVaapiDriverVaapiDevice : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VaapiDevice",
|
||||
table: "FFmpegProfile",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VaapiDriver",
|
||||
table: "FFmpegProfile",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VaapiDevice",
|
||||
table: "FFmpegProfile");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VaapiDriver",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3293
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Update_FFmpegProfileVaapiDriverVaapiDevice : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE FFmpegProfile SET VaapiDevice = '/dev/dri/renderD128'");
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE FFmpegProfile SET VaapiDriver = (SELECT IFNULL((SELECT Value AS A FROM ConfigElement WHERE Key = 'ffmpeg.vaapi_driver'), 0))");
|
||||
migrationBuilder.Sql("DELETE FROM ConfigElement WHERE Key = 'ffmpeg.vaapi_driver'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+3343
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_DurationTail : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "OfflineTail",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
newName: "TailMode");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TailCollectionType",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailCollectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailMediaItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailMultiCollectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailSmartCollectionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_Collection_TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailCollectionId",
|
||||
principalTable: "Collection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_MediaItem_TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailMediaItemId",
|
||||
principalTable: "MediaItem",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_MultiCollection_TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailMultiCollectionId",
|
||||
principalTable: "MultiCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_SmartCollection_TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
column: "TailSmartCollectionId",
|
||||
principalTable: "SmartCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_Collection_TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_MediaItem_TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_MultiCollection_TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProgramScheduleDurationItem_SmartCollection_TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ProgramScheduleDurationItem_TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TailCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TailCollectionType",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TailMediaItemId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TailMultiCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TailSmartCollectionId",
|
||||
table: "ProgramScheduleDurationItem");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "TailMode",
|
||||
table: "ProgramScheduleDurationItem",
|
||||
newName: "OfflineTail");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3346
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutAnchor_InDurationFiller : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InDurationFiller",
|
||||
table: "Playout");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3349
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutItem_IsFiller : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsFiller",
|
||||
table: "PlayoutItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsFiller",
|
||||
table: "PlayoutItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutItem_GuideFinish : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "GuideFinish",
|
||||
table: "PlayoutItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GuideFinish",
|
||||
table: "PlayoutItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_LocalLibrary_OtherVideos : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// create local other videos library
|
||||
migrationBuilder.Sql(
|
||||
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
|
||||
SELECT 'Other Videos', 4, Id FROM
|
||||
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
|
||||
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3507
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user