Compare commits

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

* add naive filler scheduling

* fix duration item length in xmltv

* show offline image for unfilled duration tail

* fix tests

* update changelog

* update dependencies
2021-10-13 21:15:16 -05:00
Jason Dove 3a6d9e9f39 update changelog for release v0.1.3-alpha [no ci] 2021-10-13 15:17:23 -05:00
Jason DoveandGitHub 7ed4b8ae3c fix startup bug (#426) 2021-10-13 13:30:26 -05:00
107 changed files with 26354 additions and 153 deletions
+26 -1
View File
@@ -4,6 +4,29 @@ 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
@@ -691,7 +714,9 @@ 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.2-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
@@ -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;
+4
View File
@@ -37,6 +37,9 @@ namespace ErsatzTV.Application.Playouts
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Title ?? string.Empty)
.IfNone("[unknown video]");
default:
return string.Empty;
}
@@ -49,6 +52,7 @@ namespace ErsatzTV.Application.Playouts
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetFuturePlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -11,21 +12,23 @@ using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts.Queries
{
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel>
public class GetFuturePlayoutItemsByIdHandler : IRequestHandler<GetFuturePlayoutItemsById, PagedPlayoutItemsViewModel>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
public GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<PagedPlayoutItemsViewModel> Handle(
GetPlayoutItemsById request,
GetFuturePlayoutItemsById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
int totalCount = await dbContext.PlayoutItems
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken);
DateTime now = DateTimeOffset.Now.UtcDateTime;
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
@@ -49,7 +52,12 @@ namespace ErsatzTV.Application.Playouts.Queries
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now)
.OrderBy(i => i.Start)
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Playouts.Queries
{
public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
}
@@ -19,6 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}
@@ -15,7 +15,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder { get; }
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
TailMode TailMode { get; }
ProgramScheduleItemCollectionType TailCollectionType { get; }
int? TailCollectionId { get; }
int? TailMultiCollectionId { get; }
int? TailSmartCollectionId { get; }
int? TailMediaItemId { get; }
string CustomTitle { get; }
GuideMode GuideMode { get; }
}
}
@@ -55,11 +55,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'");
}
if (item.OfflineTail is null)
{
return BaseError.New("[OfflineTail] is required for playout mode 'duration'");
}
break;
default:
return BaseError.New("[PlayoutMode] is invalid");
@@ -140,7 +135,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -153,7 +149,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -167,7 +164,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -181,8 +179,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
TailMode = item.TailMode,
TailCollectionType = item.TailCollectionType,
TailCollectionId = item.TailCollectionId,
TailMultiCollectionId = item.TailMultiCollectionId,
TailSmartCollectionId = item.TailSmartCollectionId,
TailMediaItemId = item.TailMediaItemId,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -20,8 +20,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
PlaybackOrder PlaybackOrder,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
TailMode TailMode,
ProgramScheduleItemCollectionType TailCollectionType,
int? TailCollectionId,
int? TailMultiCollectionId,
int? TailSmartCollectionId,
int? TailMediaItemId,
string CustomTitle,
GuideMode GuideMode) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -40,8 +40,26 @@ namespace ErsatzTV.Application.ProgramSchedules
},
duration.PlaybackOrder,
duration.PlayoutDuration,
duration.OfflineTail,
duration.CustomTitle),
duration.TailMode,
duration.TailCollectionType,
duration.TailCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailCollection)
: null,
duration.TailMultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailMultiCollection)
: null,
duration.TailSmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.TailSmartCollection)
: null,
duration.TailMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.CustomTitle,
duration.GuideMode),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -66,7 +84,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
flood.PlaybackOrder,
flood.CustomTitle),
flood.CustomTitle,
flood.GuideMode),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -92,7 +111,8 @@ namespace ErsatzTV.Application.ProgramSchedules
},
multiple.PlaybackOrder,
multiple.Count,
multiple.CustomTitle),
multiple.CustomTitle,
multiple.GuideMode),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -117,7 +137,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
one.PlaybackOrder,
one.CustomTitle),
one.CustomTitle,
one.GuideMode),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
@@ -19,8 +19,14 @@ namespace ErsatzTV.Application.ProgramSchedules
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
bool offlineTail,
string customTitle) : base(
TailMode tailMode,
ProgramScheduleItemCollectionType tailCollectionType,
MediaCollectionViewModel tailCollection,
MultiCollectionViewModel tailMultiCollection,
SmartCollectionViewModel tailSmartCollection,
NamedMediaItemViewModel tailMediaItem,
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -32,13 +38,26 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
TailMode = tailMode;
TailCollectionType = tailCollectionType;
TailCollection = tailCollection;
TailMultiCollection = tailMultiCollection;
TailSmartCollection = tailSmartCollection;
TailMediaItem = tailMediaItem;
}
public TimeSpan PlayoutDuration { get; }
public bool OfflineTail { get; }
public TailMode TailMode { get; }
public ProgramScheduleItemCollectionType TailCollectionType { get; }
public MediaCollectionViewModel TailCollection { get; }
public MultiCollectionViewModel TailMultiCollection { get; }
public SmartCollectionViewModel TailSmartCollection { get; }
public NamedMediaItemViewModel TailMediaItem { get; }
}
}
@@ -18,7 +18,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -30,7 +31,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}
@@ -19,7 +19,8 @@ namespace ErsatzTV.Application.ProgramSchedules
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -31,7 +32,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>
customTitle,
guideMode) =>
Count = count;
public int Count { get; }
@@ -18,7 +18,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
string customTitle,
GuideMode guideMode) : base(
id,
index,
startType,
@@ -30,7 +31,8 @@ namespace ErsatzTV.Application.ProgramSchedules
smartCollection,
mediaItem,
playbackOrder,
customTitle)
customTitle,
guideMode)
{
}
}
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.ProgramSchedules
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
string CustomTitle,
GuideMode GuideMode)
{
public string Name => CollectionType switch
{
@@ -30,6 +30,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.Include(i => (i as ProgramScheduleItemDuration).TailCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMultiCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailSmartCollection)
.Include(i => (i as ProgramScheduleItemDuration).TailMediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using MediatR;
@@ -19,12 +20,13 @@ namespace ErsatzTV.Application.Search.Queries
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("season", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
await GetIds(SearchIndex.MovieType, request.Query),
await GetIds(SearchIndex.ShowType, request.Query),
await GetIds(SearchIndex.SeasonType, request.Query),
await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query));
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexOtherVideos
(string Query, int PageNumber, int PageSize) : IRequest<OtherVideoCardResultsViewModel>;
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexOtherVideosHandler : IRequestHandler<QuerySearchIndexOtherVideos,
OtherVideoCardResultsViewModel>
{
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexOtherVideosHandler(ISearchIndex searchIndex, IOtherVideoRepository otherVideoRepository)
{
_searchIndex = searchIndex;
_otherVideoRepository = otherVideoRepository;
}
public async Task<OtherVideoCardResultsViewModel> Handle(
QuerySearchIndexOtherVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<OtherVideoCardViewModel> items = await _otherVideoRepository
.GetOtherVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new OtherVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Search
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
List<int> MusicVideoIds,
List<int> OtherVideoIds);
}
@@ -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))
};
@@ -111,7 +118,6 @@ namespace ErsatzTV.Application.Streaming.Queries
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
request.StartAtZero,
request.HlsRealtime);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
@@ -146,7 +152,8 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
"Channel is Offline",
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
@@ -165,7 +172,8 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath,
channel,
maybeDuration,
error.Value);
error.Value,
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -183,7 +191,8 @@ namespace ErsatzTV.Application.Streaming.Queries
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
"Channel is Offline",
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -217,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))
};
@@ -188,7 +188,6 @@ namespace ErsatzTV.Core.Tests.FFmpeg
None,
VaapiDriver.Default,
"/dev/dri/renderD128",
false,
false);
process.StartInfo.RedirectStandardError = true;
@@ -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
}
};
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public enum GuideMode
{
Normal = 0,
Filler = 1
}
}
@@ -4,6 +4,7 @@
{
Movies = 1,
Shows = 2,
MusicVideos = 3
MusicVideos = 3,
OtherVideos = 4
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class OtherVideo : MediaItem
{
public List<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class OtherVideoMetadata : Metadata
{
public int OtherVideoId { get; set; }
public OtherVideo OtherVideo { get; set; }
}
}
+1
View File
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Domain
public int? MultipleRemaining { get; set; }
public DateTime? DurationFinish { get; set; }
public bool InFlood { get; set; }
public bool InDurationFiller { get; set; }
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
+5
View File
@@ -9,12 +9,17 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public DateTime? GuideFinish { get; set; }
public string CustomTitle { get; set; }
public bool CustomGroup { get; set; }
public bool IsFiller { get; set; }
public int PlayoutId { get; set; }
public Playout Playout { get; set; }
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null;
}
}
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public GuideMode GuideMode { get; set; }
public string CustomTitle { get; set; }
public int ProgramScheduleId { get; set; }
public ProgramSchedule ProgramSchedule { get; set; }
@@ -5,6 +5,15 @@ namespace ErsatzTV.Core.Domain
public class ProgramScheduleItemDuration : ProgramScheduleItem
{
public TimeSpan PlayoutDuration { get; set; }
public bool OfflineTail { get; set; }
public TailMode TailMode { get; set; }
public ProgramScheduleItemCollectionType TailCollectionType { get; set; }
public int? TailCollectionId { get; set; }
public Collection TailCollection { get; set; }
public int? TailMediaItemId { get; set; }
public MediaItem TailMediaItem { get; set; }
public int? TailMultiCollectionId { get; set; }
public MultiCollection TailMultiCollection { get; set; }
public int? TailSmartCollectionId { get; set; }
public SmartCollection TailSmartCollection { get; set; }
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public enum TailMode
{
None = 0,
Offline = 1,
Slate = 2,
Filler = 3
}
}
@@ -159,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 &&
+18 -11
View File
@@ -324,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(
+20 -6
View File
@@ -41,7 +41,6 @@ namespace ErsatzTV.Core.FFmpeg
Option<ChannelWatermark> globalWatermark,
VaapiDriver vaapiDriver,
string vaapiDevice,
bool startAtZero,
bool hlsRealtime)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
@@ -121,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:
@@ -131,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);
@@ -149,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)
@@ -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);
}
}
+10 -4
View File
@@ -70,13 +70,19 @@ namespace ErsatzTV.Core.Iptv
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
{
var i = 0;
while (i < sorted.Count && sorted[i].IsFiller)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = i;
while (hasCustomTitle && finishIndex + 1 < sorted.Count && sorted[finishIndex + 1].CustomGroup)
while (finishIndex + 1 < sorted.Count && (hasCustomTitle && sorted[finishIndex + 1].CustomGroup ||
sorted[finishIndex + 1].IsFiller))
{
finishIndex++;
}
@@ -97,10 +103,10 @@ namespace ErsatzTV.Core.Iptv
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
// ReSharper disable once StringLiteralTypo
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
// ReSharper disable once StringLiteralTypo
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = startItem.GuideFinishOffset.HasValue
? startItem.GuideFinishOffset.Value.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty)
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
@@ -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());
}
}
}
}
+188 -26
View File
@@ -52,7 +52,7 @@ namespace ErsatzTV.Core.Scheduling
bool rebuild = false)
{
var collectionKeys = playout.ProgramSchedule.Items
.Map(CollectionKeyForItem)
.SelectMany(CollectionKeysForItem)
.Distinct()
.ToList();
@@ -118,14 +118,14 @@ namespace ErsatzTV.Core.Scheduling
{
bool isZero = item switch
{
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
_ => true
};
@@ -160,12 +160,14 @@ namespace ErsatzTV.Core.Scheduling
c => c.Value.Any(
mi => mi switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
_ => true
})).Map(c => c.Key);
if (zeroDurationCollection.IsSome)
@@ -192,9 +194,11 @@ namespace ErsatzTV.Core.Scheduling
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
{
PlaybackOrder playbackOrder = sortedScheduleItems
.First(item => CollectionKeyForItem(item) == collectionKey)
.PlaybackOrder;
// use configured playback order for primary collection, shuffle for filler
Option<ProgramScheduleItem> maybeScheduleItem = sortedScheduleItems
.FirstOrDefault(item => CollectionKeyForItem(item) == collectionKey);
PlaybackOrder playbackOrder = maybeScheduleItem
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder);
collectionEnumerators.Add(collectionKey, enumerator);
@@ -211,6 +215,15 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name,
currentTime);
// removing any items scheduled past the start anchor
// this could happen if the app was closed after scheduling items
// but before saving the anchor
int removed = playout.Items.RemoveAll(pi => pi.StartOffset >= currentTime);
if (removed > 0)
{
_logger.LogWarning("Removed {Count} schedule items beyond current start anchor", removed);
}
// start with the previously-decided schedule item
int index = sortedScheduleItems.IndexOf(startAnchor.NextScheduleItem);
@@ -219,6 +232,7 @@ namespace ErsatzTV.Core.Scheduling
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
bool inFlood = startAnchor.InFlood;
bool inDurationFiller = startAnchor.InDurationFiller;
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
@@ -234,16 +248,33 @@ namespace ErsatzTV.Core.Scheduling
currentTime,
multipleRemaining.IsSome,
durationFinish.IsSome,
inFlood);
inFlood,
inDurationFiller);
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None;
if (inDurationFiller && scheduleItem is ProgramScheduleItemDuration
{
TailMode: TailMode.Filler
})
{
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem);
}
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
foreach (CollectionKey tailCollectionKey in maybeTailCollectionKey)
{
enumerator = collectionEnumerators[tailCollectionKey];
}
await enumerator.Current.IfSomeAsync(
mediaItem =>
{
_logger.LogDebug(
"Scheduling media item: {ScheduleItemNumber} / {CollectionType} / {MediaItemId} - {MediaItemTitle} / {StartTime}",
scheduleItem.Index,
scheduleItem.CollectionType,
inDurationFiller
? (scheduleItem as ProgramScheduleItemDuration)?.TailCollectionType
: scheduleItem.CollectionType,
mediaItem.Id,
DisplayTitle(mediaItem),
itemStartTime);
@@ -253,6 +284,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -261,7 +293,8 @@ namespace ErsatzTV.Core.Scheduling
MediaItemId = mediaItem.Id,
Start = itemStartTime.UtcDateTime,
Finish = itemStartTime.UtcDateTime + version.Duration,
CustomGroup = customGroup
CustomGroup = customGroup,
IsFiller = inDurationFiller || scheduleItem.GuideMode == GuideMode.Filler
};
if (!string.IsNullOrWhiteSpace(scheduleItem.CustomTitle))
@@ -323,6 +356,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@@ -363,6 +397,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@@ -383,14 +418,34 @@ namespace ErsatzTV.Core.Scheduling
"Advancing to next schedule item after playout mode {PlayoutMode}",
"Duration");
index++;
customGroup = false;
if (duration.OfflineTail)
if (duration.TailMode == TailMode.Offline)
{
durationFinish.Do(f => currentTime = f);
}
durationFinish = None;
if (duration.TailMode != TailMode.Filler || inDurationFiller)
{
if (duration.TailMode != TailMode.None)
{
durationFinish.Do(f => currentTime = f);
}
durationFinish = None;
inDurationFiller = false;
customGroup = false;
}
else if (duration.TailMode == TailMode.Filler &&
WillFinishFillerInTime(
scheduleItem,
currentTime,
durationFinish,
collectionEnumerators))
{
inDurationFiller = true;
durationFinish.Do(
f => playoutItem.GuideFinish = f.UtcDateTime);
}
}
}
);
@@ -406,7 +461,8 @@ namespace ErsatzTV.Core.Scheduling
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish);
playout.Items.RemoveAll(
old => old.FinishOffset < playoutStart.AddHours(-4) || old.StartOffset > playoutFinish);
DateTimeOffset minCurrentTime = currentTime;
if (playout.Items.Any())
@@ -425,12 +481,62 @@ namespace ErsatzTV.Core.Scheduling
NextStart = GetStartTimeAfter(nextScheduleItem, minCurrentTime).UtcDateTime,
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
InFlood = inFlood
InFlood = inFlood,
InDurationFiller = inDurationFiller
};
return playout;
}
private static bool WillFinishFillerInTime(
ProgramScheduleItem scheduleItem,
DateTimeOffset currentTime,
Option<DateTimeOffset> durationFinish,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
{
Option<CollectionKey> maybeTailCollectionKey = Option<CollectionKey>.None;
if (scheduleItem is ProgramScheduleItemDuration
{
TailMode: TailMode.Filler
})
{
maybeTailCollectionKey = TailCollectionKeyForItem(scheduleItem);
}
foreach (CollectionKey collectionKey in maybeTailCollectionKey)
{
IMediaCollectionEnumerator enumerator = collectionEnumerators[collectionKey];
Option<int> firstId = enumerator.Current.Map(i => i.Id);
while (true)
{
foreach (MediaItem peekMediaItem in enumerator.Current)
{
MediaVersion peekVersion = peekMediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
if (currentTime + peekVersion.Duration <= durationFinish.IfNone(SystemTime.MinValueUtc))
{
return true;
}
}
enumerator.MoveNext();
if (enumerator.Current.Map(i => i.Id) == firstId)
{
return false;
}
}
}
return false;
}
private static PlayoutAnchor FindStartAnchor(
Playout playout,
DateTimeOffset start,
@@ -465,14 +571,16 @@ namespace ErsatzTV.Core.Scheduling
DateTimeOffset start,
bool inMultiple = false,
bool inDuration = false,
bool inFlood = false)
bool inFlood = false,
bool inDurationFiller = false)
{
switch (item.StartType)
{
case StartType.Fixed:
if (item is ProgramScheduleItemMultiple && inMultiple ||
item is ProgramScheduleItemDuration && inDuration ||
item is ProgramScheduleItemFlood && inFlood)
item is ProgramScheduleItemFlood && inFlood ||
item is ProgramScheduleItemDuration && inDurationFiller)
{
return start;
}
@@ -641,11 +749,22 @@ namespace ErsatzTV.Core.Scheduling
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Match(
ovm => ovm.Title ?? string.Empty,
() => "[unknown video]");
default:
return string.Empty;
}
}
private static List<CollectionKey> CollectionKeysForItem(ProgramScheduleItem item)
{
var result = new List<CollectionKey> { CollectionKeyForItem(item) };
result.AddRange(TailCollectionKeyForItem(item));
return result;
}
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
item.CollectionType switch
{
@@ -682,6 +801,49 @@ namespace ErsatzTV.Core.Scheduling
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
private static Option<CollectionKey> TailCollectionKeyForItem(ProgramScheduleItem item)
{
if (item is ProgramScheduleItemDuration { TailMode: TailMode.Filler } duration)
{
return duration.TailCollectionType switch
{
ProgramScheduleItemCollectionType.Collection => new CollectionKey
{
CollectionType = duration.TailCollectionType,
CollectionId = duration.TailCollectionId
},
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey
{
CollectionType = duration.TailCollectionType,
MediaItemId = duration.TailMediaItemId
},
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey
{
CollectionType = duration.TailCollectionType,
MediaItemId = duration.TailMediaItemId
},
ProgramScheduleItemCollectionType.Artist => new CollectionKey
{
CollectionType = duration.TailCollectionType,
MediaItemId = duration.TailMediaItemId
},
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey
{
CollectionType = duration.TailCollectionType,
MultiCollectionId = duration.TailMultiCollectionId
},
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey
{
CollectionType = duration.TailCollectionType,
SmartCollectionId = duration.TailSmartCollectionId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
}
return None;
}
private class CollectionKey : Record<CollectionKey>
{
public ProgramScheduleItemCollectionType CollectionType { get; set; }
@@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class OtherVideoConfiguration : IEntityTypeConfiguration<OtherVideo>
{
public void Configure(EntityTypeBuilder<OtherVideo> builder)
{
builder.ToTable("OtherVideo");
builder.HasMany(m => m.OtherVideoMetadata)
.WithOne(m => m.OtherVideo)
.HasForeignKey(m => m.OtherVideoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(m => m.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVideoMetadata>
{
public void Configure(EntityTypeBuilder<OtherVideoMetadata> builder)
{
builder.ToTable("OtherVideoMetadata");
builder.HasMany(mm => mm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -6,7 +6,27 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ProgramScheduleItemDurationConfiguration : IEntityTypeConfiguration<ProgramScheduleItemDuration>
{
public void Configure(EntityTypeBuilder<ProgramScheduleItemDuration> builder) =>
public void Configure(EntityTypeBuilder<ProgramScheduleItemDuration> builder)
{
builder.ToTable("ProgramScheduleDurationItem");
builder.HasOne(i => i.TailCollection)
.WithMany()
.HasForeignKey(i => i.TailCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.TailMediaItem)
.WithMany()
.HasForeignKey(i => i.TailMediaItemId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.TailMultiCollection)
.WithMany()
.HasForeignKey(i => i.TailMultiCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
}
}
}
@@ -54,6 +54,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
return result.Distinct().ToList();
}
@@ -79,6 +80,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
@@ -137,6 +139,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
var otherVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.OtherVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
}
return result;
@@ -410,6 +418,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MediaVersions)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
private async Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, int collectionId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT o.Id FROM CollectionItem ci
INNER JOIN OtherVideo o ON o.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await GetOtherVideoItems(dbContext, ids);
}
private static Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, IEnumerable<int> otherVideoIds) =>
dbContext.OtherVideos
.Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync();
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
{
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class OtherVideoRepository : IOtherVideoRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public OtherVideoRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
{
_dbConnection = dbConnection;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<OtherVideo> maybeExisting = await dbContext.OtherVideos
.AsNoTracking()
.Include(ov => ov.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.Include(ov => ov.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(ov => ov.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<OtherVideo>>(
new MediaItemScanResult<OtherVideo>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddOtherVideo(dbContext, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN OtherVideo O on MV.OtherVideoId = O.Id
INNER JOIN MediaItem MI on O.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT O.Id
FROM OtherVideo O
INNER JOIN MediaItem MI on O.Id = MI.Id
INNER JOIN MediaVersion MV on O.Id = MV.OtherVideoId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int otherVideoId in ids)
{
OtherVideo othervide = await dbContext.OtherVideos.FindAsync(otherVideoId);
dbContext.OtherVideos.Remove(othervide);
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Filter(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.OtherVideo)
.Include(ovm => ovm.Artwork)
.OrderBy(ovm => ovm.SortTitle)
.ToListAsync();
}
private static async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> AddOtherVideo(
TvContext dbContext,
int libraryPathId,
string path)
{
try
{
var otherVideo = new OtherVideo
{
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
},
Streams = new List<MediaStream>()
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.OtherVideos.AddAsync(otherVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(otherVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(otherVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<OtherVideo>(otherVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -97,6 +97,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(mi => mi.Id)
@@ -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>
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");
}
}
}
@@ -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");
}
}
}
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");
}
}
}
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");
}
}
}
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)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_OtherVideo_OtherVideoMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Tag",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Studio",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "MetadataGuid",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoId",
table: "MediaVersion",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Genre",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Artwork",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Actor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "OtherVideo",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherVideo", x => x.Id);
table.ForeignKey(
name: "FK_OtherVideo_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OtherVideoMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OtherVideoId = table.Column<int>(type: "INTEGER", nullable: false),
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", nullable: true),
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true),
SortTitle = table.Column<string>(type: "TEXT", nullable: true),
Year = table.Column<int>(type: "INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false),
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherVideoMetadata", x => x.Id);
table.ForeignKey(
name: "FK_OtherVideoMetadata_OtherVideo_OtherVideoId",
column: x => x.OtherVideoId,
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tag_OtherVideoMetadataId",
table: "Tag",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_OtherVideoId",
table: "MediaVersion",
column: "OtherVideoId");
migrationBuilder.CreateIndex(
name: "IX_Genre_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_OtherVideoMetadataId",
table: "Artwork",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_OtherVideoMetadata_OtherVideoId",
table: "OtherVideoMetadata",
column: "OtherVideoId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_OtherVideoMetadata_OtherVideoMetadataId",
table: "Artwork",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_OtherVideo_OtherVideoId",
table: "MediaVersion",
column: "OtherVideoId",
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Tag_OtherVideoMetadata_OtherVideoMetadataId",
table: "Tag",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_OtherVideoMetadata_OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_OtherVideo_OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Tag_OtherVideoMetadata_OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "OtherVideoMetadata");
migrationBuilder.DropTable(
name: "OtherVideo");
migrationBuilder.DropIndex(
name: "IX_Tag_OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Studio_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Actor");
}
}
}
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_ProgramScheduleItem_GuideMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "GuideMode",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GuideMode",
table: "ProgramScheduleItem");
}
}
}
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.10");
.HasAnnotation("ProductVersion", "5.0.11");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -43,6 +43,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("Order")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.HasColumnType("TEXT");
@@ -65,6 +68,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -151,6 +156,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
@@ -172,6 +180,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -525,6 +535,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@@ -541,6 +554,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -815,6 +830,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoId")
.HasColumnType("INTEGER");
b.Property<string>("RFrameRate")
.HasColumnType("TEXT");
@@ -835,6 +853,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoId");
b.HasIndex("OtherVideoId");
b.ToTable("MediaVersion");
});
@@ -859,6 +879,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@@ -875,6 +898,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -1055,6 +1080,46 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MusicVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<int>("OtherVideoId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OtherVideoId");
b.ToTable("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
@@ -1097,6 +1162,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<DateTime>("Finish")
.HasColumnType("TEXT");
b.Property<DateTime?>("GuideFinish")
.HasColumnType("TEXT");
b.Property<bool>("IsFiller")
.HasColumnType("INTEGER");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
@@ -1241,6 +1312,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("CustomTitle")
.HasColumnType("TEXT");
b.Property<int>("GuideMode")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
@@ -1430,6 +1504,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@@ -1446,6 +1523,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -1493,6 +1572,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@@ -1509,6 +1591,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@@ -1727,6 +1811,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MusicVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@@ -1808,12 +1899,35 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.Property<int?>("TailCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("TailCollectionType")
.HasColumnType("INTEGER");
b.Property<int?>("TailMediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("TailMode")
.HasColumnType("INTEGER");
b.Property<int?>("TailMultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int?>("TailSmartCollectionId")
.HasColumnType("INTEGER");
b.HasIndex("TailCollectionId");
b.HasIndex("TailMediaItemId");
b.HasIndex("TailMultiCollectionId");
b.HasIndex("TailSmartCollectionId");
b.ToTable("ProgramScheduleDurationItem");
});
@@ -2010,6 +2124,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Actors")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Actors")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
.HasForeignKey("SeasonMetadataId");
@@ -2060,6 +2178,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Artwork")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SeasonMetadataId")
@@ -2185,6 +2308,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Genres")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId");
@@ -2299,6 +2426,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("MediaVersions")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
.WithMany("MediaVersions")
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
@@ -2321,6 +2453,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Guids")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Guids")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
@@ -2400,6 +2536,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", "OtherVideo")
.WithMany("OtherVideoMetadata")
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
@@ -2422,6 +2569,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b1.Property<DateTime?>("DurationFinish")
.HasColumnType("TEXT");
b1.Property<bool>("InDurationFiller")
.HasColumnType("INTEGER");
b1.Property<bool>("InFlood")
.HasColumnType("INTEGER");
@@ -2647,6 +2797,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Studios")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId");
@@ -2685,6 +2839,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Tags")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId");
@@ -2834,6 +2993,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Artist");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.OtherVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@@ -2903,6 +3071,33 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Collection", "TailCollection")
.WithMany()
.HasForeignKey("TailCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "TailMediaItem")
.WithMany()
.HasForeignKey("TailMediaItemId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "TailMultiCollection")
.WithMany()
.HasForeignKey("TailMultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "TailSmartCollection")
.WithMany()
.HasForeignKey("TailSmartCollectionId");
b.Navigation("TailCollection");
b.Navigation("TailMediaItem");
b.Navigation("TailMultiCollection");
b.Navigation("TailSmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
@@ -3164,6 +3359,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
@@ -3251,6 +3461,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.Navigation("MediaVersions");
b.Navigation("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.Navigation("Episodes");
@@ -60,6 +60,7 @@ namespace ErsatzTV.Infrastructure.Search
public const string ArtistType = "artist";
public const string MusicVideoType = "music_video";
public const string EpisodeType = "episode";
public const string OtherVideoType = "other_video";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
@@ -121,6 +122,9 @@ namespace ErsatzTV.Infrastructure.Search
case Episode episode:
await UpdateEpisode(searchRepository, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
}
}
@@ -218,6 +222,9 @@ namespace ErsatzTV.Infrastructure.Search
case Episode episode:
await UpdateEpisode(searchRepository, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
}
}
}
@@ -747,6 +754,44 @@ namespace ErsatzTV.Infrastructure.Search
}
}
}
private async Task UpdateOtherVideo(ISearchRepository searchRepository, OtherVideo otherVideo)
{
Option<OtherVideoMetadata> maybeMetadata = otherVideo.OtherVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
OtherVideoMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, otherVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, OtherVideoType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, otherVideo.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
await AddLanguages(searchRepository, doc, otherVideo.MediaVersions);
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, otherVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.OtherVideo = null;
_logger.LogWarning(ex, "Error indexing other video with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(
doc.Get(TypeField),
@@ -773,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search
EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.EpisodeNumber}"
.ToLowerInvariant(),
OtherVideoMetadata ovm => $"{ovm.OriginalTitle}".ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};
+2 -2
View File
@@ -21,8 +21,8 @@
<PackageReference Include="Markdig" Version="0.26.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" 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>
+1 -1
View File
@@ -213,7 +213,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, TailMode.None, ProgramScheduleItemCollectionType.Collection, null, null, null, null, null, GuideMode.Normal));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
+42
View File
@@ -59,6 +59,10 @@
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (_data.OtherVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")">@_data.OtherVideoCards.Count Other Videos</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
@@ -220,6 +224,30 @@
}
</MudContainer>
}
@if (_data.OtherVideoCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveOtherVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@@ -278,6 +306,7 @@
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.Append(_data.OtherVideoCards.OrderBy(ov => ov.SortTitle))
.ToList();
}
@@ -361,6 +390,19 @@
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
}
private async Task RemoveOtherVideoFromCollection(MediaCardViewModel vm)
{
if (vm is OtherVideoCardViewModel otherVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { otherVideo.OtherVideoId }
};
await RemoveItemsWithConfirmation("other video", $"{otherVideo.Title}", request);
}
}
private async Task RemoveItemsWithConfirmation(
string entityType,
+6 -3
View File
@@ -98,7 +98,8 @@ namespace ErsatzTV.Pages
_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
_selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
_selectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
@@ -107,10 +108,11 @@ namespace ErsatzTV.Pages
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count;
musicVideoIds.Count + otherVideoIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
@@ -127,7 +129,8 @@ namespace ErsatzTV.Pages
seasonIds,
episodeIds,
artistIds,
musicVideoIds);
musicVideoIds,
otherVideoIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
+158
View File
@@ -0,0 +1,158 @@
@page "/media/other/videos"
@page "/media/other/videos/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<OtherVideoList>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<FragmentLetterAnchor TCard="OtherVideoCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/other/videos"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private OtherVideoCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:other_video" : $"type:other_video AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexOtherVideos(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/other/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/other/videos/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}
+1 -1
View File
@@ -182,7 +182,7 @@
if (_selectedPlayoutId.HasValue)
{
PagedPlayoutItemsViewModel data =
await _mediator.Send(new GetPlayoutItemsById(_selectedPlayoutId.Value, state.Page, state.PageSize));
await _mediator.Send(new GetFuturePlayoutItemsById(_selectedPlayoutId.Value, state.Page, state.PageSize));
return new TableData<PlayoutItemViewModel>
{
TotalItems = data.TotalCount,
+93 -9
View File
@@ -94,7 +94,7 @@
}
</MudSelect>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudSelect Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<MudSelect Class="mt-3" Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
{
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
@@ -154,7 +154,7 @@
SearchFunc="@SearchArtists"
ToStringFunc="@(s => s?.Name)"/>
}
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@switch (_selectedItem.CollectionType)
{
case ProgramScheduleItemCollectionType.MultiCollection:
@@ -183,10 +183,82 @@
</MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
</MudElement>
<MudSelect Class="mt-3" Label="Tail Mode" @bind-Value="@_selectedItem.TailMode" For="@(() => _selectedItem.TailMode)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)">
<MudSelectItem Value="@TailMode.None">(none)</MudSelectItem>
<MudSelectItem Value="@TailMode.Offline">Offline</MudSelectItem>
<MudSelectItem Value="@TailMode.Filler">Filler</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="Tail Collection Type" @bind-Value="_selectedItem.TailCollectionType" For="@(() => _selectedItem.TailCollectionType)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)">
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
{
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
}
</MudSelect>
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.Collection)
{
<MudAutocomplete Class="mt-3"
T="MediaCollectionViewModel"
Label="Collection"
@bind-value="_selectedItem.TailCollection"
SearchFunc="@SearchMediaCollections"
ToStringFunc="@(c => c?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.MultiCollection)
{
<MudAutocomplete Class="mt-3"
T="MultiCollectionViewModel"
Label="Multi Collection"
@bind-value="_selectedItem.TailMultiCollection"
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.SmartCollection)
{
<MudAutocomplete Class="mt-3"
T="SmartCollectionViewModel"
Label="Smart Collection"
@bind-value="_selectedItem.TailSmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Show"
@bind-value="_selectedItem.TailMediaItem"
SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(s => s?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Television Season"
@bind-value="_selectedItem.TailMediaItem"
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(s => s?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
@if (_selectedItem.TailCollectionType == ProgramScheduleItemCollectionType.Artist)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Artist"
@bind-value="_selectedItem.TailMediaItem"
SearchFunc="@SearchArtists"
ToStringFunc="@(s => s?.Name)"
Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration || _selectedItem.TailMode != TailMode.Filler)"/>
}
<MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/>
<MudSelect Class="mt-3" Label="Guide Mode" @bind-Value="@_selectedItem.GuideMode" For="@(() => _selectedItem.GuideMode)">
<MudSelectItem Value="@GuideMode.Normal">Normal</MudSelectItem>
<MudSelectItem Value="@GuideMode.Filler">Filler</MudSelectItem>
</MudSelect>
</MudCardContent>
</MudCard>
</EditForm>
@@ -274,7 +346,8 @@
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode
};
switch (item)
@@ -284,7 +357,12 @@
break;
case ProgramScheduleItemDurationViewModel duration:
result.PlayoutDuration = duration.PlayoutDuration;
result.OfflineTail = duration.OfflineTail;
result.TailMode = duration.TailMode;
result.TailCollectionType = duration.TailCollectionType;
result.TailCollection = duration.TailCollection;
result.TailMultiCollection = duration.TailMultiCollection;
result.TailSmartCollection = duration.TailSmartCollection;
result.TailMediaItem = duration.TailMediaItem;
break;
}
@@ -359,8 +437,14 @@
item.PlaybackOrder,
item.MultipleCount,
item.PlayoutDuration,
item.PlayoutMode == PlayoutMode.Duration ? item.OfflineTail.IfNone(false) : null,
item.CustomTitle)).ToList();
item.TailMode,
item.TailCollectionType,
item.TailCollection?.Id,
item.TailMultiCollection?.Id,
item.TailSmartCollection?.Id,
item.TailMediaItem?.MediaItemId,
item.CustomTitle,
item.GuideMode)).ToList();
Seq<BaseError> errorMessages = await _mediator.Send(new ReplaceProgramScheduleItems(Id, items)).Map(e => e.LeftToSeq());
+69
View File
@@ -63,6 +63,11 @@
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
if (_otherVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")" Style="margin-bottom: auto; margin-top: auto">@_otherVideos.Count Other Videos</MudLink>
}
<div style="margin-left: auto">
<MudTooltip Text="Add All To Collection">
<MudButton Variant="Variant.Filled"
@@ -249,6 +254,34 @@
}
</MudContainer>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@@ -258,6 +291,7 @@
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private ArtistCardResultsViewModel _artists;
protected override async Task OnInitializedAsync()
@@ -270,6 +304,7 @@
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
}
@@ -284,6 +319,7 @@
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.ToList();
}
@@ -396,6 +432,27 @@
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
private string GetMoviesLink()
@@ -463,6 +520,17 @@
}
return uri;
}
private string GetOtherVideosLink()
{
var uri = "/media/other/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private async Task AddAllToCollection(MouseEventArgs _)
{
@@ -474,6 +542,7 @@
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
"search results");
}
+1 -1
View File
@@ -197,7 +197,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, TailMode.None, ProgramScheduleItemCollectionType.Collection, null, null, null, null, null, GuideMode.Normal));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
+1 -1
View File
@@ -225,7 +225,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, null, null, TailMode.None, ProgramScheduleItemCollectionType.Collection, null, null, null, null, null, GuideMode.Normal));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
@@ -40,8 +39,9 @@ namespace ErsatzTV.Services.RunOnce
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
await repo.Upsert(ConfigElementKey.FFmpegSaveReports, false);
}
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux))
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux) &&
System.IO.Directory.Exists("/dev/dri"))
{
ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>();
IMemoryCache memoryCache = scope.ServiceProvider.GetRequiredService<IMemoryCache>();
+1
View File
@@ -53,6 +53,7 @@
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/other/videos">Other Videos</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="/media/collections">Collections</MudNavLink>
+2
View File
@@ -271,6 +271,7 @@ namespace ErsatzTV
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>();
@@ -284,6 +285,7 @@ namespace ErsatzTV
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IMusicVideoFolderScanner, MusicVideoFolderScanner>();
services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
@@ -12,9 +12,9 @@ namespace ErsatzTV.ViewModels
{
private ProgramScheduleItemCollectionType _collectionType;
private int? _multipleCount;
private bool? _offlineTail;
private TimeSpan? _playoutDuration;
private TimeSpan? _startTime;
private ProgramScheduleItemCollectionType _tailCollectionType;
public int Id { get; set; }
public int Index { get; set; }
@@ -40,10 +40,12 @@ namespace ErsatzTV.ViewModels
Collection = null;
MultiCollection = null;
MediaItem = null;
SmartCollection = null;
OnPropertyChanged(nameof(Collection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(MediaItem));
OnPropertyChanged(nameof(SmartCollection));
}
if (_collectionType == ProgramScheduleItemCollectionType.MultiCollection)
@@ -83,14 +85,39 @@ namespace ErsatzTV.ViewModels
set => _playoutDuration = value;
}
public bool? OfflineTail
public TailMode TailMode { get; set; }
public ProgramScheduleItemCollectionType TailCollectionType
{
get => PlayoutMode == PlayoutMode.Duration ? _offlineTail : null;
set => _offlineTail = value;
get => _tailCollectionType;
set
{
if (_tailCollectionType != value)
{
_tailCollectionType = value;
TailCollection = null;
TailMultiCollection = null;
TailMediaItem = null;
TailSmartCollection = null;
OnPropertyChanged(nameof(TailCollection));
OnPropertyChanged(nameof(TailMultiCollection));
OnPropertyChanged(nameof(TailMediaItem));
OnPropertyChanged(nameof(TailSmartCollection));
}
}
}
public MediaCollectionViewModel TailCollection { get; set; }
public MultiCollectionViewModel TailMultiCollection { get; set; }
public SmartCollectionViewModel TailSmartCollection { get; set; }
public NamedMediaItemViewModel TailMediaItem { get; set; }
public string CustomTitle { get; set; }
public GuideMode GuideMode { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

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