Compare commits

...

10 Commits

Author SHA1 Message Date
Jason Dove
1aac2f13c9 scanning and poster improvements (#24)
* first pass at refresh-all-metadata by source

* add version to startup logs

* lock media source during refresh

* fix local media source "name" in collection editor

* optimize scanning so playouts only rebuild when necessary

* support more poster file types

* more scanning improvements; check for missing posters during scans
2021-02-14 17:04:50 +00:00
Jason Dove
2c9d4d796a improve scanning, add refresh button to media cards (#23)
* support .etvignore files to exclude folders (and child folders) from scanner

* include top-level folder in scanner

* don't always rescan "other" media sources

* add metadata/poster refresh button to media cards
2021-02-14 03:21:38 +00:00
Jason Dove
9d40caebd6 rework media layout (#22)
* replace media items tables with card grids

* style cleanup

* add basic paging

* sort and page in the db

* optimize sql for movies

* support movie posters

* resize movie posters and store in cache with channel logos

* fix bug preventing folders with more than 50 chars as local media sources

* support tv posters
2021-02-14 00:15:18 +00:00
Jason Dove
0b5a6f9dcd appease the c# compiler (#17) 2021-02-13 02:32:50 +00:00
Jason Dove
76495c1f7b use time pickers for schedule editor (#16) 2021-02-13 00:46:31 +00:00
Jason Dove
d0d1186b92 attempt to fix release on windows 2021-02-12 16:33:42 -06:00
Jason Dove
04ab4ee60f add version information (#15) 2021-02-12 22:26:05 +00:00
Jason Dove
e62074cc26 add basic logging ui (#14) 2021-02-12 22:18:44 +00:00
Jason Dove
db054ece24 Database migrations (#13)
* remove last use of dbcontextfactory

* add initial migration
2021-02-12 12:50:04 +00:00
Jason Dove
c2d8a54a47 catch ffprobe errors parsing statistics (#11) 2021-02-12 03:09:52 +00:00
107 changed files with 5948 additions and 390 deletions

View File

@@ -42,8 +42,8 @@ jobs:
release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name"
dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli"
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then

5
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<InformationalVersion>develop</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -26,7 +26,8 @@ COPY ErsatzTV.Core/. ./ErsatzTV.Core/
COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base

View File

@@ -0,0 +1,9 @@
namespace ErsatzTV.Application
{
public interface IMediaCard
{
string Title { get; }
string SortTitle { get; }
string Subtitle { get; }
}
}

View File

@@ -1,9 +1,7 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
@@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands
{
public class SaveImageToDiskHandler : IRequestHandler<SaveImageToDisk, Either<BaseError, string>>
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly IImageCache _imageCache;
static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider();
public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, string>> Handle(
public Task<Either<BaseError, string>> Handle(
SaveImageToDisk request,
CancellationToken cancellationToken)
{
try
{
byte[] hash = Crypto.ComputeHash(request.Buffer);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex);
if (!Directory.Exists(FileSystemLayout.ImageCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder);
}
await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken);
return hex;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
CancellationToken cancellationToken) => _imageCache.SaveImage(request.Buffer);
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
string Exception,
string RenderedMessage,
string Properties);
}

View File

@@ -0,0 +1,16 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
}
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Logs.Queries
{
public record GetRecentLogEntries : IRequest<List<LogEntryViewModel>>;
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Logs.Mapper;
namespace ErsatzTV.Application.Logs.Queries
{
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, List<LogEntryViewModel>>
{
private readonly ILogRepository _logRepository;
public GetRecentLogEntriesHandler(ILogRepository logRepository) => _logRepository = logRepository;
public Task<List<LogEntryViewModel>> Handle(GetRecentLogEntries request, CancellationToken cancellationToken) =>
_logRepository.GetRecentLogEntries().Map(list => list.Map(ProjectToViewModel).ToList());
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToSimpleMediaCollection
(int MediaCollectionId, List<int> ItemIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

View File

@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToSimpleMediaCollectionHandler : MediatR.IRequestHandler<AddItemsToSimpleMediaCollection,
Either<BaseError, Unit>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMediaItemRepository _mediaItemRepository;
public AddItemsToSimpleMediaCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMediaItemRepository mediaItemRepository)
{
_mediaCollectionRepository = mediaCollectionRepository;
_mediaItemRepository = mediaItemRepository;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToSimpleMediaCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(ApplyAddItemsRequest)
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(RequestParameters parameters)
{
foreach (MediaItem item in parameters.ItemsToAdd.Where(
item => parameters.Collection.Items.All(i => i.Id != item.Id)))
{
parameters.Collection.Items.Add(item);
}
await _mediaCollectionRepository.Update(parameters.Collection);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>>
Validate(AddItemsToSimpleMediaCollection request) =>
(await SimpleMediaCollectionMustExist(request), await ValidateItems(request))
.Apply(
(simpleMediaCollectionToUpdate, itemsToAdd) =>
new RequestParameters(simpleMediaCollectionToUpdate, itemsToAdd));
private Task<Validation<BaseError, SimpleMediaCollection>> SimpleMediaCollectionMustExist(
AddItemsToSimpleMediaCollection updateSimpleMediaCollection) =>
_mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist."));
private Task<Validation<BaseError, List<MediaItem>>> ValidateItems(
AddItemsToSimpleMediaCollection request) =>
LoadAllMediaItems(request)
.Map(v => v.ToValidation<BaseError>("MediaItem does not exist"));
private async Task<Option<List<MediaItem>>> LoadAllMediaItems(AddItemsToSimpleMediaCollection request)
{
var items = (await request.ItemIds.Map(async id => await _mediaItemRepository.Get(id)).Sequence())
.ToList();
if (items.Any(i => i.IsNone))
{
return None;
}
return items.Somes().ToList();
}
private record RequestParameters(SimpleMediaCollection Collection, List<MediaItem> ItemsToAdd);
}
}

View File

@@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetSimpleMediaCollectionWithItemsById
(int Id) : IRequest<Option<Tuple<MediaCollectionViewModel, List<MediaItemSearchResultViewModel>>>>;
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetSimpleMediaCollectionWithItemsByIdHandler : IRequestHandler<GetSimpleMediaCollectionWithItemsById,
Option<Tuple<MediaCollectionViewModel, List<MediaItemSearchResultViewModel>>>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetSimpleMediaCollectionWithItemsByIdHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<Option<Tuple<MediaCollectionViewModel, List<MediaItemSearchResultViewModel>>>> Handle(
GetSimpleMediaCollectionWithItemsById request,
CancellationToken cancellationToken)
{
Option<SimpleMediaCollection> maybeCollection =
await _mediaCollectionRepository.GetSimpleMediaCollectionWithItems(request.Id);
return maybeCollection.Match<Option<Tuple<MediaCollectionViewModel, List<MediaItemSearchResultViewModel>>>>(
c => Tuple(ProjectToViewModel(c), c.Items.Map(ProjectToSearchViewModel).ToList()),
None);
}
}
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemResults(int Count, List<AggregateMediaItemViewModel> DataPage);
}

View File

@@ -1,4 +1,9 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(string Source, string Title, int Count, string Duration);
public record AggregateMediaItemViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster);
}

View File

@@ -16,6 +16,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
@@ -27,7 +28,8 @@ namespace ErsatzTV.Application.MediaItems.Commands
IConfigElementRepository configElementRepository,
ISmartCollectionBuilder smartCollectionBuilder,
ILocalMetadataProvider localMetadataProvider,
ILocalStatisticsProvider localStatisticsProvider)
ILocalStatisticsProvider localStatisticsProvider,
ILocalPosterProvider localPosterProvider)
{
_mediaItemRepository = mediaItemRepository;
_mediaSourceRepository = mediaSourceRepository;
@@ -35,6 +37,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
_smartCollectionBuilder = smartCollectionBuilder;
_localMetadataProvider = localMetadataProvider;
_localStatisticsProvider = localStatisticsProvider;
_localPosterProvider = localPosterProvider;
}
public Task<Either<BaseError, MediaItemViewModel>> Handle(
@@ -50,6 +53,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem);
await _localMetadataProvider.RefreshMetadata(parameters.MediaItem);
await _localPosterProvider.RefreshPoster(parameters.MediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem);
return ProjectToViewModel(parameters.MediaItem);

View File

@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemPoster : RefreshMediaItem
{
public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId)
{
}
}
}

View File

@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemPosterHandler : MediatR.IRequestHandler<RefreshMediaItemPoster,
Either<BaseError, Unit>>
{
private readonly ILocalPosterProvider _localPosterProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemPosterHandler(
IMediaItemRepository mediaItemRepository,
ILocalPosterProvider localPosterProvider)
{
_mediaItemRepository = mediaItemRepository;
_localPosterProvider = localPosterProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemPoster request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshPoster)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemPoster request) =>
MediaItemMustExist(request);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(RefreshMediaItemPoster request) =>
_mediaItemRepository.Get(request.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {request.MediaItemId} does not exist."));
private Task<Unit> RefreshPoster(MediaItem mediaItem) =>
_localPosterProvider.RefreshPoster(mediaItem).ToUnit();
}
}

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaItems
{
@@ -9,5 +10,33 @@ namespace ErsatzTV.Application.MediaItems
mediaItem.Id,
mediaItem.MediaSourceId,
mediaItem.Path);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
new(
mediaItem.Id,
GetSourceName(mediaItem.Source),
mediaItem.Metadata.MediaType.ToString(),
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(this MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow &&
Optional(mediaItem.Metadata.SeasonNumber).IsSome &&
Optional(mediaItem.Metadata.EpisodeNumber).IsSome
? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
private static string GetDisplayDuration(MediaItem mediaItem) =>
string.Format(
mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata.Duration);
private static string GetSourceName(MediaSource source) =>
source switch
{
LocalMediaSource lms => lms.Folder,
_ => source.Name
};
}
}

View File

@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems
{
public record MediaItemSearchResultViewModel(
int Id,
string Source,
string MediaType,
string Title,
string Duration);
}

View File

@@ -1,9 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAggregateMediaItems
(MediaType MediaType, string SearchString) : IRequest<List<AggregateMediaItemViewModel>>;
(MediaType MediaType, int PageNumber, int PageSize) : IRequest<AggregateMediaItemResults>;
}

View File

@@ -1,46 +1,43 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, List<AggregateMediaItemViewModel>>
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, AggregateMediaItemResults>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<AggregateMediaItemViewModel>> Handle(
public async Task<AggregateMediaItemResults> Handle(
GetAggregateMediaItems request,
CancellationToken cancellationToken)
{
IEnumerable<MediaItem> allItems = await _mediaItemRepository.GetAll(request.MediaType);
int count = await _mediaItemRepository.GetCountByType(request.MediaType);
if (!string.IsNullOrEmpty(request.SearchString))
{
allItems = allItems.Filter(
i => i.Metadata?.Title.Contains(request.SearchString, StringComparison.OrdinalIgnoreCase) ==
true);
}
IEnumerable<MediaItemSummary> allItems = await _mediaItemRepository.GetPageByType(
request.MediaType,
request.PageNumber,
request.PageSize);
return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map(
group => new AggregateMediaItemViewModel(
group.Key.Name,
group.Key.Title,
group.Count(),
group.Count() == 1 ? DisplayDuration(group.Head()) : string.Empty))
var results = allItems
.Map(
s => new AggregateMediaItemViewModel(
s.MediaItemId,
s.Title,
s.Subtitle,
s.SortTitle,
s.Poster))
.ToList();
}
private static string DisplayDuration(MediaItem mediaItem) => string.Format(
mediaItem.Metadata?.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata?.Duration);
return new AggregateMediaItemResults(count, results);
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
@@ -15,9 +16,9 @@ namespace ErsatzTV.Application.MediaItems.Queries
public GetAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<MediaItemViewModel>> Handle(
public Task<List<MediaItemViewModel>> Handle(
GetAllMediaItems request,
CancellationToken cancellationToken) =>
(await _mediaItemRepository.GetAll()).Map(ProjectToViewModel).ToList();
_mediaItemRepository.GetAll().Map(list => list.Map(ProjectToViewModel).ToList());
}
}

View File

@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record SearchAllMediaItems(string SearchString) : IRequest<List<MediaItemSearchResultViewModel>>;
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class SearchAllMediaItemsHandler : IRequestHandler<SearchAllMediaItems, List<MediaItemSearchResultViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public SearchAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public Task<List<MediaItemSearchResultViewModel>>
Handle(SearchAllMediaItems request, CancellationToken cancellationToken) =>
_mediaItemRepository.Search(request.SearchString).Map(list => list.Map(ProjectToSearchViewModel).ToList());
}
}

View File

@@ -41,28 +41,28 @@ namespace ErsatzTV.Application.MediaSources.Commands
Folder = folder
});
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource createCollection)
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource request)
{
List<string> allNames = await _mediaSourceRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
var result2 = Optional(request.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media source name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name);
return (result1, result2).Apply((_, _) => request.Name);
}
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource createCollection)
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource request)
{
List<string> allFolders = await _mediaSourceRepository.GetAll()
.Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
return Optional(createCollection.Folder)
return Optional(request.Folder)
.Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Folder must not belong to another media source");
}

View File

@@ -1,9 +1,11 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record ScanLocalMediaSource(int MediaSourceId) : IRequest<Either<BaseError, string>>,
public record ScanLocalMediaSource(int MediaSourceId, ScanningMode ScanningMode) :
IRequest<Either<BaseError, string>>,
IBackgroundServiceRequest;
}

View File

@@ -3,37 +3,52 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalMediaSourceHandler : IRequestHandler<ScanLocalMediaSource, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILocalMediaScanner _localMediaScanner;
private readonly IMediaSourceRepository _mediaSourceRepository;
public ScanLocalMediaSourceHandler(
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
ILocalMediaScanner localMediaScanner)
ILocalMediaScanner localMediaScanner,
IEntityLocker entityLocker)
{
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_localMediaScanner = localMediaScanner;
_entityLocker = entityLocker;
}
public Task<Either<BaseError, string>>
Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(
p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath)
.Map(_ => p.LocalMediaSource.Name))
.MapT(parameters => PerformScan(request, parameters).Map(_ => parameters.LocalMediaSource.Folder))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> PerformScan(ScanLocalMediaSource request, RequestParameters parameters)
{
await _localMediaScanner.ScanLocalMediaSource(
parameters.LocalMediaSource,
parameters.FFprobePath,
request.ScanningMode);
_entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(ScanLocalMediaSource request) =>
(await LocalMediaSourceMustExist(request), await ValidateFFprobePath())
.Apply((localMediaSource, ffprobePath) => new RequestParameters(localMediaSource, ffprobePath));

View File

@@ -86,7 +86,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from Plex server {PlexServer}: {Error}",
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.Name,
error.Value);

View File

@@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -10,7 +11,8 @@ namespace ErsatzTV.Application.MediaSources
mediaSource switch
{
LocalMediaSource lms => new LocalMediaSourceViewModel(lms.Id, lms.Name, lms.Folder),
PlexMediaSource pms => ProjectToViewModel(pms)
PlexMediaSource pms => ProjectToViewModel(pms),
_ => throw new NotSupportedException($"Unsupported media source {mediaSource.GetType().Name}")
};
internal static PlexMediaSourceViewModel ProjectToViewModel(PlexMediaSource plexMediaSource) =>

View File

@@ -1,4 +1,5 @@
using System.Threading;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -62,8 +63,14 @@ namespace ErsatzTV.Application.Playouts.Commands
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>
(await _programScheduleRepository.Get(createPlayout.ProgramScheduleId))
.ToValidation<BaseError>("ProgramSchedule does not exist.");
(await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId))
.ToValidation<BaseError>("ProgramSchedule does not exist.")
.Bind(ProgramScheduleMustHaveItems);
private Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(ProgramSchedule programSchedule) =>
Optional(programSchedule)
.Filter(ps => ps.Items.Any())
.ToValidation<BaseError>("Program schedule must have items");
private Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(CreatePlayout createPlayout) =>
Optional(createPlayout.ProgramSchedulePlayoutType)

View File

@@ -28,7 +28,7 @@ namespace ErsatzTV.Application.Playouts
? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
public static string GetDisplayDuration(MediaItem mediaItem) =>
private static string GetDisplayDuration(MediaItem mediaItem) =>
string.Format(
mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata.Duration);

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -88,7 +89,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaCollectionId = item.MediaCollectionId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
OfflineTail = item.OfflineTail.GetValueOrDefault()
}
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
}
}

View File

@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules
{
@@ -40,7 +41,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.Index,
one.StartType,
one.StartTime,
MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection))
MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection)),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
}
}

View File

@@ -22,6 +22,9 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id) =>
throw new NotSupportedException();
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItems(int id) =>
throw new NotSupportedException();
public Task<Option<TelevisionMediaCollection>> GetTelevisionMediaCollection(int id) =>
throw new NotSupportedException();
@@ -42,7 +45,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task Update(SimpleMediaCollection collection) => throw new NotSupportedException();
public Task InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException();
public Task<bool> InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException();
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems) =>
throw new NotSupportedException();

View File

@@ -0,0 +1,4 @@
namespace ErsatzTV.Core.AggregateModels
{
public record MediaItemSummary(int MediaItemId, string Title, string SortTitle, string Subtitle, string Poster);
}

View File

@@ -0,0 +1,12 @@
using System;
namespace ErsatzTV.Core.Domain
{
public record LogEntry(
int Id,
DateTime Timestamp,
string Level,
string Exception,
string RenderedMessage,
string Properties);
}

View File

@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public MediaMetadata Metadata { get; set; }
public DateTime? LastWriteTime { get; set; }
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }

View File

@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Domain
public string AudioCodec { get; set; }
public MediaType MediaType { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public string Subtitle { get; set; }
public string Description { get; set; }
public int? SeasonNumber { get; set; }

View File

@@ -285,7 +285,7 @@ namespace ErsatzTV.Core.FFmpeg
bool hasVideoFilters = _videoFilters.Any();
if (hasVideoFilters)
{
(string filter, string finalLabel) = GenerateFilter(_videoFilters, StreamType.Video);
(string filter, string finalLabel) = GenerateVideoFilter(_videoFilters);
complexFilter.Append(filter);
videoLabel = finalLabel;
}
@@ -297,7 +297,7 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(';');
}
(string filter, string finalLabel) = GenerateFilter(_audioFilters, StreamType.Audio);
(string filter, string finalLabel) = GenerateAudioFilter(_audioFilters);
complexFilter.Append(filter);
audioLabel = finalLabel;
}
@@ -348,20 +348,16 @@ namespace ErsatzTV.Core.FFmpeg
};
}
private FilterResult GenerateFilter(Queue<string> filterQueue, StreamType streamType)
private FilterResult GenerateVideoFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "null", 'v');
private FilterResult GenerateAudioFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "anull", 'a');
private static FilterResult GenerateFilter(Queue<string> filterQueue, string nullFilter, char av)
{
var filter = new StringBuilder();
var index = 0;
string nullFilter = streamType switch
{
StreamType.Audio => "anull",
StreamType.Video => "null"
};
char av = streamType switch
{
StreamType.Audio => 'a',
StreamType.Video => 'v'
};
filter.Append($"[0:{av}]{nullFilter}[{av}{index}]");
while (filterQueue.TryDequeue(out string result))
{
@@ -372,11 +368,5 @@ namespace ErsatzTV.Core.FFmpeg
}
private record FilterResult(string Filter, string FinalLabel);
private enum StreamType
{
Audio,
Video
}
}
}

View File

@@ -13,6 +13,8 @@ namespace ErsatzTV.Core
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
public static readonly string ImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");

View File

@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
{
public interface IImageCache
{
Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width);
Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer);
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace ErsatzTV.Core.Interfaces.Locking
{
public interface IEntityLocker
{
public event EventHandler OnMediaSourceChanged;
public bool LockMediaSource(int mediaSourceId);
public bool UnlockMediaSource(int mediaSourceId);
public bool IsMediaSourceLocked(int mediaSourceId);
}
}

View File

@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalFileSystem
{
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource);
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem);
public bool ShouldRefreshPoster(MediaItem mediaItem);
}
}

View File

@@ -1,11 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaScanner
{
Task<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath);
Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode);
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalPosterProvider
{
Task RefreshPoster(MediaItem mediaItem);
}
}

View File

@@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalStatisticsProvider
{
Task RefreshStatistics(string ffprobePath, MediaItem mediaItem);
Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
}
}

View File

@@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ISmartCollectionBuilder
{
Task RefreshSmartCollections(MediaItem mediaItem);
Task<bool> RefreshSmartCollections(MediaItem mediaItem);
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILogRepository
{
public Task<List<LogEntry>> GetRecentLogEntries();
}
}

View File

@@ -11,6 +11,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<SimpleMediaCollection> Add(SimpleMediaCollection collection);
public Task<Option<MediaCollection>> Get(int id);
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id);
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItems(int id);
public Task<Option<TelevisionMediaCollection>> GetTelevisionMediaCollection(int id);
public Task<List<SimpleMediaCollection>> GetSimpleMediaCollections();
public Task<List<MediaCollection>> GetAll();
@@ -19,7 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
public Task<Option<List<MediaItem>>> GetTelevisionMediaCollectionItems(int id);
public Task Update(SimpleMediaCollection collection);
public Task InsertOrIgnore(TelevisionMediaCollection collection);
public Task<bool> InsertOrIgnore(TelevisionMediaCollection collection);
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems);
public Task Delete(int mediaCollectionId);
public Task DeleteEmptyTelevisionCollections();

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -10,9 +11,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<int> Add(MediaItem mediaItem);
public Task<Option<MediaItem>> Get(int id);
public Task<List<MediaItem>> GetAll();
public Task<List<MediaItem>> GetAll(MediaType mediaType);
public Task<List<MediaItem>> Search(string searchString);
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize);
public Task<int> GetCountByType(MediaType mediaType);
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
public Task Update(MediaItem mediaItem);
public Task<bool> Update(MediaItem mediaItem);
public Task Delete(int mediaItemId);
}
}

View File

@@ -26,15 +26,15 @@ namespace ErsatzTV.Core.Iptv
using var ms = new MemoryStream();
using var xml = XmlWriter.Create(ms);
xml.WriteStartDocument();
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
foreach (Channel channel in _channels)
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", channel.Number.ToString());
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
@@ -72,7 +72,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Title);
xml.WriteEndElement(); // title
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown

View File

@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalFileSystem : ILocalFileSystem
{
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) =>
Directory.Exists(localMediaSource.Folder);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource)
{
Seq<string> allDirectories = Directory
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.ToSeq()
.Add(localMediaSource.Folder);
// remove any directories with an .etvignore file locally, or in any parent directory
Seq<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith))
.Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
.Filter(file => KnownExtensions.Contains(Path.GetExtension(file)))
.OrderBy(identity)
.ToSeq();
}
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem)
{
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
return modified // media item has been modified
|| mediaItem.Metadata == null // media item has no metadata
|| mediaItem.Metadata.MediaType != localMediaSource.MediaType; // media item is typed incorrectly
}
public bool ShouldRefreshPoster(MediaItem mediaItem) =>
string.IsNullOrWhiteSpace(mediaItem.Poster);
private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore"));
// see https://support.emby.media/support/solutions/articles/44001159102-movie-naming
private static bool IsExtrasFolder(string path) =>
ExtraFolderNames.Contains(Path.GetFileName(path)?.ToLowerInvariant());
// @formatter:off
private static readonly Seq<string> KnownExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraFolderNames = Seq(
"extras", "specials", "shorts", "scenes", "featurettes",
"behind the scenes", "deleted scenes", "interviews", "trailers");
// @formatter:on
}
}

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -15,7 +14,9 @@ namespace ErsatzTV.Core.Metadata
{
public class LocalMediaScanner : ILocalMediaScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMediaScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
@@ -28,77 +29,59 @@ namespace ErsatzTV.Core.Metadata
IPlayoutRepository playoutRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
ILocalPosterProvider localPosterProvider,
ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder,
ILogger<LocalMediaScanner> logger)
ILogger<LocalMediaScanner> logger,
ILocalFileSystem localFileSystem)
{
_mediaItemRepository = mediaItemRepository;
_playoutRepository = playoutRepository;
_localStatisticsProvider = localStatisticsProvider;
_localMetadataProvider = localMetadataProvider;
_localPosterProvider = localPosterProvider;
_smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder;
_logger = logger;
_localFileSystem = localFileSystem;
}
public async Task<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath)
public async Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode)
{
if (!Directory.Exists(localMediaSource.Folder))
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source folder {Folder} does not exist; skipping scan",
"Media source folder {Folder} does not exist or is inaccessible; skipping scan",
localMediaSource.Folder);
return Unit.Default;
return unit;
}
List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
var modifiedPlayoutIds = new List<int>();
// remove files that no longer exist
// add new files
// refresh metadata for any files where it is missing
var knownExtensions = new List<string>
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v",
".avi", ".wmv", ".mov", ".mkv", ".ts"
};
var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.Filter(file => knownExtensions.Contains(Path.GetExtension(file)))
.ToSeq();
Seq<string> allFiles = _localFileSystem.FindRelevantVideos(localMediaSource);
// check if the media item exists
(Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = allFiles.Map(
s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s))
.Partition();
// TODO: flag as missing? delete after some period of time?
var removedMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq();
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(removedMediaItems));
foreach (MediaItem mediaItem in removedMediaItems)
{
_logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path);
await _mediaItemRepository.Delete(mediaItem.Id);
}
// remove media items that no longer exist
var missingMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq();
await RemoveMissingItems(missingMediaItems);
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(missingMediaItems));
// if exists, check if the file was modified
// also, try to re-categorize "other" by refreshing metadata
Seq<MediaItem> modifiedMediaItems = existingMediaItems.Filter(
mediaItem =>
{
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
return modified || mediaItem.Metadata == null || mediaItem.Metadata.MediaType == MediaType.Other;
});
Seq<MediaItem> staleMetadataMediaItems = scanningMode == ScanningMode.RescanAll
? existingMediaItems
: existingMediaItems.Filter(i => _localFileSystem.ShouldRefreshMetadata(localMediaSource, i));
Seq<MediaItem> modifiedMediaItems = await RefreshMetadataForItems(ffprobePath, staleMetadataMediaItems);
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
foreach (MediaItem mediaItem in modifiedMediaItems)
{
_logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path);
await RefreshMetadata(mediaItem, ffprobePath);
}
// if new, add and store mtime, refresh metadata
var addedMediaItems = new Seq<MediaItem>();
var addedMediaItems = new List<MediaItem>();
foreach (string path in newFiles)
{
_logger.LogDebug("Adding new media item {MediaItem}", path);
@@ -114,7 +97,12 @@ namespace ErsatzTV.Core.Metadata
addedMediaItems.Add(mediaItem);
}
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems));
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems.ToSeq()));
Seq<MediaItem> stalePosterMediaItems = existingMediaItems
.Filter(_localFileSystem.ShouldRefreshPoster)
.Concat(addedMediaItems);
await RefreshPosterForItems(stalePosterMediaItems);
foreach (int playoutId in modifiedPlayoutIds.Distinct())
{
@@ -128,14 +116,68 @@ namespace ErsatzTV.Core.Metadata
Task.CompletedTask);
}
return Unit.Default;
return unit;
}
private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath)
private async Task<Seq<MediaItem>> RefreshMetadataForItems(
string ffprobePath,
Seq<MediaItem> staleMetadataMediaItems)
{
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
var modifiedMediaItems = new List<MediaItem>();
foreach (MediaItem mediaItem in staleMetadataMediaItems)
{
_logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path);
if (await RefreshMetadata(mediaItem, ffprobePath))
{
// only queue playout rebuilds for media items
// where the duration or collections have changed
modifiedMediaItems.Add(mediaItem);
}
}
return modifiedMediaItems.ToSeq();
}
private async Task RefreshPosterForItems(Seq<MediaItem> stalePosterMediaItems)
{
(Seq<MediaItem> movies, Seq<MediaItem> episodes) = stalePosterMediaItems
.Map(i => Optional(i).Filter(i2 => i2.Metadata?.MediaType == MediaType.TvShow).ToEither(i))
.Partition();
// there's a 1:1 movie:poster, so refresh all
foreach (MediaItem movie in movies)
{
_logger.LogDebug("Refreshing poster for media item {MediaItem}", movie.Path);
await _localPosterProvider.RefreshPoster(movie);
}
// we currently have 1 poster per series, so pick the first from each group
IEnumerable<MediaItem> episodesToRefresh = episodes.GroupBy(e => e.Metadata.Title)
.SelectMany(g => (Option<MediaItem>) g.FirstOrDefault());
foreach (MediaItem episode in episodesToRefresh)
{
_logger.LogDebug("Refreshing poster for media item {MediaItem}", episode.Path);
await _localPosterProvider.RefreshPoster(episode);
}
}
private async Task RemoveMissingItems(Seq<MediaItem> removedMediaItems)
{
// TODO: flag as missing? delete after some period of time?
foreach (MediaItem mediaItem in removedMediaItems)
{
_logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path);
await _mediaItemRepository.Delete(mediaItem.Id);
}
}
private async Task<bool> RefreshMetadata(MediaItem mediaItem, string ffprobePath)
{
bool durationChange = await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
await _localMetadataProvider.RefreshMetadata(mediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
bool collectionChange = await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
return durationChange || collectionChange;
}
}
}

View File

@@ -43,6 +43,10 @@ namespace ErsatzTV.Core.Metadata
mediaItem.Metadata.MediaType = metadata.MediaType;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.Subtitle = metadata.Subtitle;
mediaItem.Metadata.SortTitle =
(metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ")
? metadata.Title?.Substring(4)
: metadata.Title;
mediaItem.Metadata.Description = metadata.Description;
mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber;
mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber;

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalPosterProvider : ILocalPosterProvider
{
private static readonly string[] SupportedExtensions = { "jpg", "jpeg", "png", "gif", "tbn" };
private readonly IImageCache _imageCache;
private readonly ILogger<LocalPosterProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalPosterProvider(
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
ILogger<LocalPosterProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_logger = logger;
}
public async Task RefreshPoster(MediaItem mediaItem)
{
Option<string> maybePosterPath = mediaItem.Metadata.MediaType switch
{
MediaType.Movie => RefreshMoviePoster(mediaItem),
MediaType.TvShow => RefreshTelevisionPoster(mediaItem),
_ => None
};
await maybePosterPath.Match(
path => SavePosterToDisk(mediaItem, path),
Task.CompletedTask);
}
private static Option<string> RefreshMoviePoster(MediaItem mediaItem)
{
string folder = Path.GetDirectoryName(mediaItem.Path);
if (folder != null)
{
IEnumerable<string> possiblePaths = SupportedExtensions.Collect(
e => new[] { $"poster.{e}", Path.GetFileNameWithoutExtension(mediaItem.Path) + $"-poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
private Option<string> RefreshTelevisionPoster(MediaItem mediaItem)
{
string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName;
if (folder != null)
{
IEnumerable<string> possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
}
}

View File

@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using static LanguageExt.Prelude;
@@ -14,19 +15,33 @@ namespace ErsatzTV.Core.Metadata
{
public class LocalStatisticsProvider : ILocalStatisticsProvider
{
private readonly ILogger<LocalStatisticsProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalStatisticsProvider(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem)
public LocalStatisticsProvider(
IMediaItemRepository mediaItemRepository,
ILogger<LocalStatisticsProvider> logger)
{
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
await ApplyStatisticsUpdate(mediaItem, metadata);
_mediaItemRepository = mediaItemRepository;
_logger = logger;
}
private async Task ApplyStatisticsUpdate(
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
{
try
{
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, metadata);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item at {Path}", mediaItem.Path);
return false;
}
}
private async Task<bool> ApplyStatisticsUpdate(
MediaItem mediaItem,
MediaMetadata metadata)
{
@@ -35,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
mediaItem.Metadata = new MediaMetadata();
}
bool durationChange = mediaItem.Metadata.Duration != metadata.Duration;
mediaItem.Metadata.Duration = metadata.Duration;
mediaItem.Metadata.AudioCodec = metadata.AudioCodec;
mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio;
@@ -44,7 +61,7 @@ namespace ErsatzTV.Core.Metadata
mediaItem.Metadata.VideoCodec = metadata.VideoCodec;
mediaItem.Metadata.VideoScanType = metadata.VideoScanType;
await _mediaItemRepository.Update(mediaItem);
return await _mediaItemRepository.Update(mediaItem) && durationChange;
}
private Task<FFprobe> GetProbeOutput(string ffprobePath, MediaItem mediaItem)

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningMode
{
Default = 0,
RescanAll = 1
}
}

View File

@@ -16,12 +16,16 @@ namespace ErsatzTV.Core.Metadata
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task RefreshSmartCollections(MediaItem mediaItem)
public async Task<bool> RefreshSmartCollections(MediaItem mediaItem)
{
var results = new List<bool>();
foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem))
{
await _mediaCollectionRepository.InsertOrIgnore(collection);
results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection));
}
return results.Any(identity);
}
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)

View File

@@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class GenericIntegerIdConfiguration : IEntityTypeConfiguration<GenericIntegerId>
{
public void Configure(EntityTypeBuilder<GenericIntegerId> builder) =>
builder.HasNoKey();
builder.HasNoKey().ToView(null);
}
}

View File

@@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class MediaCollectionSummaryConfiguration : IEntityTypeConfiguration<MediaCollectionSummary>
{
public void Configure(EntityTypeBuilder<MediaCollectionSummary> builder) =>
builder.HasNoKey();
builder.HasNoKey().ToView(null);
}
}

View File

@@ -0,0 +1,12 @@
using ErsatzTV.Core.AggregateModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MediaItemSummaryConfiguration : IEntityTypeConfiguration<MediaItemSummary>
{
public void Configure(EntityTypeBuilder<MediaItemSummary> builder) =>
builder.HasNoKey().ToView(null);
}
}

View File

@@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data
{
public class LogContext : DbContext
{
public LogContext(DbContextOptions<LogContext> options)
: base(options)
{
}
public DbSet<LogEntry> LogEntries { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<LogEntry>().ToTable("Logs");
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class LogRepository : ILogRepository
{
private readonly LogContext _logContext;
public LogRepository(LogContext logContext) => _logContext = logContext;
public Task<List<LogEntry>> GetRecentLogEntries() =>
_logContext.LogEntries.OrderByDescending(e => e.Id).Take(100).ToListAsync();
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
@@ -29,6 +30,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollection(int id) =>
Get(id).Map(c => c.OfType<SimpleMediaCollection>().HeadOrNone());
public Task<Option<SimpleMediaCollection>> GetSimpleMediaCollectionWithItems(int id) =>
_dbContext.SimpleMediaCollections
.Include(s => s.Items)
.ThenInclude(i => i.Source)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<Option<TelevisionMediaCollection>> GetTelevisionMediaCollection(int id) =>
Get(id).Map(c => c.OfType<TelevisionMediaCollection>().HeadOrNone());
@@ -61,7 +69,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
collection => collection switch
{
SimpleMediaCollection s => SimpleItems(s),
TelevisionMediaCollection t => TelevisionItems(t)
TelevisionMediaCollection t => TelevisionItems(t),
_ => throw new NotSupportedException($"Unsupported collection type {collection.GetType().Name}")
}).Bind(x => x.Sequence());
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id) =>
@@ -76,15 +85,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return _dbContext.SaveChangesAsync();
}
public async Task InsertOrIgnore(TelevisionMediaCollection collection)
public async Task<bool> InsertOrIgnore(TelevisionMediaCollection collection)
{
if (!_dbContext.TelevisionMediaCollections.Any(
existing => existing.ShowTitle == collection.ShowTitle &&
existing.SeasonNumber == collection.SeasonNumber))
{
await _dbContext.TelevisionMediaCollections.AddAsync(collection);
await _dbContext.SaveChangesAsync();
return await _dbContext.SaveChangesAsync() > 0;
}
// no change
return false;
}
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems) =>

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -22,25 +24,83 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public Task<Option<MediaItem>> Get(int id) =>
_dbContext.MediaItems.SingleOrDefaultAsync(i => i.Id == id).Map(Optional);
_dbContext.MediaItems
.Include(i => i.Source)
.SingleOrDefaultAsync(i => i.Id == id)
.Map(Optional);
public Task<List<MediaItem>> GetAll() => _dbContext.MediaItems.ToListAsync();
public Task<List<MediaItem>> GetAll(MediaType mediaType) =>
_dbContext.MediaItems
.Include(i => i.Source)
.Filter(i => i.Metadata.MediaType == mediaType)
.ToListAsync();
public Task<List<MediaItem>> Search(string searchString)
{
IQueryable<MediaItem> data = from c in _dbContext.MediaItems.Include(c => c.Source) select c;
if (!string.IsNullOrEmpty(searchString))
{
data = data.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
}
return data.ToListAsync();
}
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize) =>
mediaType switch
{
MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
Id AS MediaItemId,
Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle,
substr(Metadata_Aired, 1, 4) AS Subtitle,
Poster
FROM MediaItems WHERE Metadata_MediaType=2
ORDER BY Metadata_SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync(),
MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
min(Id) AS MediaItemId,
Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle,
count(*) || ' Episodes' AS Subtitle,
max(Poster) AS Poster
FROM MediaItems WHERE Metadata_MediaType=1
GROUP BY Metadata_Title, Metadata_SortTitle
ORDER BY Metadata_SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync(),
_ => Task.FromResult(new List<MediaItemSummary>())
};
public Task<int> GetCountByType(MediaType mediaType) =>
mediaType switch
{
MediaType.Movie => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
.CountAsync(),
MediaType.TvShow => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
.GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle })
.CountAsync(),
_ => Task.FromResult(0)
};
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId) =>
_dbContext.MediaItems
.Filter(i => i.MediaSourceId == mediaSourceId)
.ToListAsync();
public async Task Update(MediaItem mediaItem)
public async Task<bool> Update(MediaItem mediaItem)
{
_dbContext.MediaItems.Update(mediaItem);
await _dbContext.SaveChangesAsync();
return await _dbContext.SaveChangesAsync() > 0;
}
public async Task Delete(int mediaItemId)

View File

@@ -32,6 +32,7 @@ namespace ErsatzTV.Infrastructure.Data
// support raw sql queries
public DbSet<MediaCollectionSummary> MediaCollectionSummaries { get; set; }
public DbSet<GenericIntegerId> GenericIntegerIds { get; set; }
public DbSet<MediaItemSummary> MediaItemSummaries { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory);
@@ -39,6 +40,11 @@ namespace ErsatzTV.Infrastructure.Data
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Ignore<MediaCollectionSummary>();
builder.Ignore<GenericIntegerId>();
builder.Ignore<MediaItemSummary>();
builder.ApplyConfigurationsFromAssembly(typeof(TvContext).Assembly);
}
}

View File

@@ -2,12 +2,18 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
<PackageReference Include="Refit" Version="6.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace ErsatzTV.Infrastructure.Images
{
public class ImageCache : IImageCache
{
private static readonly SHA1CryptoServiceProvider Crypto;
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
public async Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width)
{
await using var inStream = new MemoryStream(imageBuffer);
using var image = await Image.LoadAsync(inStream);
Size size = height.HasValue ? new Size { Height = height.Value } : new Size { Width = width.Value };
image.Mutate(
i => i.Resize(
new ResizeOptions
{
Mode = ResizeMode.Max,
Size = size
}));
await using var outStream = new MemoryStream();
await image.SaveAsync(outStream, new JpegEncoder { Quality = 90 });
return await SaveImage(outStream.ToArray());
}
public async Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer)
{
try
{
byte[] hash = Crypto.ComputeHash(imageBuffer);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex);
if (!Directory.Exists(FileSystemLayout.ImageCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder);
}
await File.WriteAllBytesAsync(fileName, imageBuffer);
return hex;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.Locking;
namespace ErsatzTV.Infrastructure.Locking
{
public class EntityLocker : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
public event EventHandler OnMediaSourceChanged;
public bool LockMediaSource(int mediaSourceId)
{
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
{
OnMediaSourceChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockMediaSource(int mediaSourceId)
{
if (_lockedMediaSources.TryRemove(mediaSourceId, out byte _))
{
OnMediaSourceChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool IsMediaSourceLocked(int mediaSourceId) =>
_lockedMediaSources.ContainsKey(mediaSourceId);
}
}

View File

@@ -0,0 +1,876 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
[Migration("20210212105010_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.ToTable("GenericIntegerIds");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.ToTable("MediaCollectionSummaries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,718 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Initial : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"ConfigElements",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>("TEXT", nullable: true),
Value = table.Column<string>("TEXT", nullable: true)
},
constraints: table => { table.PrimaryKey("PK_ConfigElements", x => x.Id); });
migrationBuilder.CreateTable(
"GenericIntegerIds",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { });
migrationBuilder.CreateTable(
"MediaCollections",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true)
},
constraints: table => { table.PrimaryKey("PK_MediaCollections", x => x.Id); });
migrationBuilder.CreateTable(
"MediaCollectionSummaries",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false),
Name = table.Column<string>("TEXT", nullable: true),
ItemCount = table.Column<int>("INTEGER", nullable: false),
IsSimple = table.Column<bool>("INTEGER", nullable: false)
},
constraints: table => { });
migrationBuilder.CreateTable(
"MediaSources",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SourceType = table.Column<int>("INTEGER", nullable: false),
Name = table.Column<string>("TEXT", nullable: true)
},
constraints: table => { table.PrimaryKey("PK_MediaSources", x => x.Id); });
migrationBuilder.CreateTable(
"ProgramSchedules",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
MediaCollectionPlaybackOrder = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { table.PrimaryKey("PK_ProgramSchedules", x => x.Id); });
migrationBuilder.CreateTable(
"Resolutions",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
Height = table.Column<int>("INTEGER", nullable: false),
Width = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { table.PrimaryKey("PK_Resolutions", x => x.Id); });
migrationBuilder.CreateTable(
"SimpleMediaCollections",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_SimpleMediaCollections", x => x.Id);
table.ForeignKey(
"FK_SimpleMediaCollections_MediaCollections_Id",
x => x.Id,
"MediaCollections",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionMediaCollections",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ShowTitle = table.Column<string>("TEXT", nullable: true),
SeasonNumber = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionMediaCollections", x => x.Id);
table.ForeignKey(
"FK_TelevisionMediaCollections_MediaCollections_Id",
x => x.Id,
"MediaCollections",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"LocalMediaSources",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MediaType = table.Column<int>("INTEGER", nullable: false),
Folder = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalMediaSources", x => x.Id);
table.ForeignKey(
"FK_LocalMediaSources_MediaSources_Id",
x => x.Id,
"MediaSources",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"MediaItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MediaSourceId = table.Column<int>("INTEGER", nullable: false),
Path = table.Column<string>("TEXT", nullable: true),
Metadata_Duration = table.Column<TimeSpan>("TEXT", nullable: true),
Metadata_SampleAspectRatio = table.Column<string>("TEXT", nullable: true),
Metadata_DisplayAspectRatio = table.Column<string>("TEXT", nullable: true),
Metadata_VideoCodec = table.Column<string>("TEXT", nullable: true),
Metadata_AudioCodec = table.Column<string>("TEXT", nullable: true),
Metadata_MediaType = table.Column<int>("INTEGER", nullable: true),
Metadata_Title = table.Column<string>("TEXT", nullable: true),
Metadata_Subtitle = table.Column<string>("TEXT", nullable: true),
Metadata_Description = table.Column<string>("TEXT", nullable: true),
Metadata_SeasonNumber = table.Column<int>("INTEGER", nullable: true),
Metadata_EpisodeNumber = table.Column<int>("INTEGER", nullable: true),
Metadata_ContentRating = table.Column<string>("TEXT", nullable: true),
Metadata_Aired = table.Column<DateTime>("TEXT", nullable: true),
Metadata_VideoScanType = table.Column<int>("INTEGER", nullable: true),
Metadata_Width = table.Column<int>("INTEGER", nullable: true),
Metadata_Height = table.Column<int>("INTEGER", nullable: true),
LastWriteTime = table.Column<DateTime>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaItems", x => x.Id);
table.ForeignKey(
"FK_MediaItems_MediaSources_MediaSourceId",
x => x.MediaSourceId,
"MediaSources",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlexMediaSources",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ProductVersion = table.Column<string>("TEXT", nullable: true),
ClientIdentifier = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexMediaSources", x => x.Id);
table.ForeignKey(
"FK_PlexMediaSources_MediaSources_Id",
x => x.Id,
"MediaSources",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ProgramScheduleItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Index = table.Column<int>("INTEGER", nullable: false),
StartTime = table.Column<TimeSpan>("TEXT", nullable: true),
MediaCollectionId = table.Column<int>("INTEGER", nullable: false),
ProgramScheduleId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgramScheduleItems", x => x.Id);
table.ForeignKey(
"FK_ProgramScheduleItems_MediaCollections_MediaCollectionId",
x => x.MediaCollectionId,
"MediaCollections",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_ProgramScheduleItems_ProgramSchedules_ProgramScheduleId",
x => x.ProgramScheduleId,
"ProgramSchedules",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"FFmpegProfiles",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
ThreadCount = table.Column<int>("INTEGER", nullable: false),
Transcode = table.Column<bool>("INTEGER", nullable: false),
ResolutionId = table.Column<int>("INTEGER", nullable: false),
NormalizeResolution = table.Column<bool>("INTEGER", nullable: false),
VideoCodec = table.Column<string>("TEXT", nullable: true),
NormalizeVideoCodec = table.Column<bool>("INTEGER", nullable: false),
VideoBitrate = table.Column<int>("INTEGER", nullable: false),
VideoBufferSize = table.Column<int>("INTEGER", nullable: false),
AudioCodec = table.Column<string>("TEXT", nullable: true),
NormalizeAudioCodec = table.Column<bool>("INTEGER", nullable: false),
AudioBitrate = table.Column<int>("INTEGER", nullable: false),
AudioBufferSize = table.Column<int>("INTEGER", nullable: false),
AudioVolume = table.Column<int>("INTEGER", nullable: false),
AudioChannels = table.Column<int>("INTEGER", nullable: false),
AudioSampleRate = table.Column<int>("INTEGER", nullable: false),
NormalizeAudio = table.Column<bool>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FFmpegProfiles", x => x.Id);
table.ForeignKey(
"FK_FFmpegProfiles_Resolutions_ResolutionId",
x => x.ResolutionId,
"Resolutions",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"MediaItemSimpleMediaCollection",
table => new
{
ItemsId = table.Column<int>("INTEGER", nullable: false),
SimpleMediaCollectionsId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey(
"PK_MediaItemSimpleMediaCollection",
x => new { x.ItemsId, x.SimpleMediaCollectionsId });
table.ForeignKey(
"FK_MediaItemSimpleMediaCollection_MediaItems_ItemsId",
x => x.ItemsId,
"MediaItems",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_MediaItemSimpleMediaCollection_SimpleMediaCollections_SimpleMediaCollectionsId",
x => x.SimpleMediaCollectionsId,
"SimpleMediaCollections",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlexMediaSourceConnections",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
IsActive = table.Column<bool>("INTEGER", nullable: false),
Uri = table.Column<string>("TEXT", nullable: true),
PlexMediaSourceId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexMediaSourceConnections", x => x.Id);
table.ForeignKey(
"FK_PlexMediaSourceConnections_PlexMediaSources_PlexMediaSourceId",
x => x.PlexMediaSourceId,
"PlexMediaSources",
"Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
"PlexMediaSourceLibraries",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>("TEXT", nullable: true),
Name = table.Column<string>("TEXT", nullable: true),
MediaType = table.Column<int>("INTEGER", nullable: false),
PlexMediaSourceId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexMediaSourceLibraries", x => x.Id);
table.ForeignKey(
"FK_PlexMediaSourceLibraries_PlexMediaSources_PlexMediaSourceId",
x => x.PlexMediaSourceId,
"PlexMediaSources",
"Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
"ProgramScheduleDurationItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlayoutDuration = table.Column<TimeSpan>("TEXT", nullable: false),
OfflineTail = table.Column<bool>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgramScheduleDurationItems", x => x.Id);
table.ForeignKey(
"FK_ProgramScheduleDurationItems_ProgramScheduleItems_Id",
x => x.Id,
"ProgramScheduleItems",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ProgramScheduleFloodItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgramScheduleFloodItems", x => x.Id);
table.ForeignKey(
"FK_ProgramScheduleFloodItems_ProgramScheduleItems_Id",
x => x.Id,
"ProgramScheduleItems",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ProgramScheduleMultipleItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Count = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgramScheduleMultipleItems", x => x.Id);
table.ForeignKey(
"FK_ProgramScheduleMultipleItems_ProgramScheduleItems_Id",
x => x.Id,
"ProgramScheduleItems",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ProgramScheduleOneItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgramScheduleOneItems", x => x.Id);
table.ForeignKey(
"FK_ProgramScheduleOneItems_ProgramScheduleItems_Id",
x => x.Id,
"ProgramScheduleItems",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Channels",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UniqueId = table.Column<Guid>("TEXT", nullable: false),
Number = table.Column<int>("INTEGER", nullable: false),
Name = table.Column<string>("TEXT", nullable: true),
Logo = table.Column<string>("TEXT", nullable: true),
FFmpegProfileId = table.Column<int>("INTEGER", nullable: false),
StreamingMode = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
table.ForeignKey(
"FK_Channels_FFmpegProfiles_FFmpegProfileId",
x => x.FFmpegProfileId,
"FFmpegProfiles",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Playouts",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ChannelId = table.Column<int>("INTEGER", nullable: false),
ProgramScheduleId = table.Column<int>("INTEGER", nullable: false),
ProgramSchedulePlayoutType = table.Column<int>("INTEGER", nullable: false),
Anchor_NextScheduleItemId = table.Column<int>("INTEGER", nullable: true),
Anchor_NextStart = table.Column<DateTimeOffset>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Playouts", x => x.Id);
table.ForeignKey(
"FK_Playouts_Channels_ChannelId",
x => x.ChannelId,
"Channels",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Playouts_ProgramScheduleItems_Anchor_NextScheduleItemId",
x => x.Anchor_NextScheduleItemId,
"ProgramScheduleItems",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Playouts_ProgramSchedules_ProgramScheduleId",
x => x.ProgramScheduleId,
"ProgramSchedules",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlayoutItems",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MediaItemId = table.Column<int>("INTEGER", nullable: false),
Start = table.Column<DateTimeOffset>("TEXT", nullable: false),
Finish = table.Column<DateTimeOffset>("TEXT", nullable: false),
PlayoutId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayoutItems", x => x.Id);
table.ForeignKey(
"FK_PlayoutItems_MediaItems_MediaItemId",
x => x.MediaItemId,
"MediaItems",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_PlayoutItems_Playouts_PlayoutId",
x => x.PlayoutId,
"Playouts",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"PlayoutProgramScheduleItemAnchors",
table => new
{
PlayoutId = table.Column<int>("INTEGER", nullable: false),
ProgramScheduleId = table.Column<int>("INTEGER", nullable: false),
MediaCollectionId = table.Column<int>("INTEGER", nullable: false),
EnumeratorState_Seed = table.Column<int>("INTEGER", nullable: true),
EnumeratorState_Index = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey(
"PK_PlayoutProgramScheduleItemAnchors",
x => new { x.PlayoutId, x.ProgramScheduleId, x.MediaCollectionId });
table.ForeignKey(
"FK_PlayoutProgramScheduleItemAnchors_MediaCollections_MediaCollectionId",
x => x.MediaCollectionId,
"MediaCollections",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_PlayoutProgramScheduleItemAnchors_Playouts_PlayoutId",
x => x.PlayoutId,
"Playouts",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_PlayoutProgramScheduleItemAnchors_ProgramSchedules_ProgramScheduleId",
x => x.ProgramScheduleId,
"ProgramSchedules",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Channels_FFmpegProfileId",
"Channels",
"FFmpegProfileId");
migrationBuilder.CreateIndex(
"IX_Channels_Number",
"Channels",
"Number",
unique: true);
migrationBuilder.CreateIndex(
"IX_ConfigElements_Key",
"ConfigElements",
"Key",
unique: true);
migrationBuilder.CreateIndex(
"IX_FFmpegProfiles_ResolutionId",
"FFmpegProfiles",
"ResolutionId");
migrationBuilder.CreateIndex(
"IX_MediaCollections_Name",
"MediaCollections",
"Name",
unique: true);
migrationBuilder.CreateIndex(
"IX_MediaItems_MediaSourceId",
"MediaItems",
"MediaSourceId");
migrationBuilder.CreateIndex(
"IX_MediaItemSimpleMediaCollection_SimpleMediaCollectionsId",
"MediaItemSimpleMediaCollection",
"SimpleMediaCollectionsId");
migrationBuilder.CreateIndex(
"IX_MediaSources_Name",
"MediaSources",
"Name",
unique: true);
migrationBuilder.CreateIndex(
"IX_PlayoutItems_MediaItemId",
"PlayoutItems",
"MediaItemId");
migrationBuilder.CreateIndex(
"IX_PlayoutItems_PlayoutId",
"PlayoutItems",
"PlayoutId");
migrationBuilder.CreateIndex(
"IX_PlayoutProgramScheduleItemAnchors_MediaCollectionId",
"PlayoutProgramScheduleItemAnchors",
"MediaCollectionId");
migrationBuilder.CreateIndex(
"IX_PlayoutProgramScheduleItemAnchors_ProgramScheduleId",
"PlayoutProgramScheduleItemAnchors",
"ProgramScheduleId");
migrationBuilder.CreateIndex(
"IX_Playouts_Anchor_NextScheduleItemId",
"Playouts",
"Anchor_NextScheduleItemId");
migrationBuilder.CreateIndex(
"IX_Playouts_ChannelId",
"Playouts",
"ChannelId");
migrationBuilder.CreateIndex(
"IX_Playouts_ProgramScheduleId",
"Playouts",
"ProgramScheduleId");
migrationBuilder.CreateIndex(
"IX_PlexMediaSourceConnections_PlexMediaSourceId",
"PlexMediaSourceConnections",
"PlexMediaSourceId");
migrationBuilder.CreateIndex(
"IX_PlexMediaSourceLibraries_PlexMediaSourceId",
"PlexMediaSourceLibraries",
"PlexMediaSourceId");
migrationBuilder.CreateIndex(
"IX_ProgramScheduleItems_MediaCollectionId",
"ProgramScheduleItems",
"MediaCollectionId");
migrationBuilder.CreateIndex(
"IX_ProgramScheduleItems_ProgramScheduleId",
"ProgramScheduleItems",
"ProgramScheduleId");
migrationBuilder.CreateIndex(
"IX_ProgramSchedules_Name",
"ProgramSchedules",
"Name",
unique: true);
migrationBuilder.CreateIndex(
"IX_TelevisionMediaCollections_ShowTitle_SeasonNumber",
"TelevisionMediaCollections",
new[] { "ShowTitle", "SeasonNumber" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"ConfigElements");
migrationBuilder.DropTable(
"GenericIntegerIds");
migrationBuilder.DropTable(
"LocalMediaSources");
migrationBuilder.DropTable(
"MediaCollectionSummaries");
migrationBuilder.DropTable(
"MediaItemSimpleMediaCollection");
migrationBuilder.DropTable(
"PlayoutItems");
migrationBuilder.DropTable(
"PlayoutProgramScheduleItemAnchors");
migrationBuilder.DropTable(
"PlexMediaSourceConnections");
migrationBuilder.DropTable(
"PlexMediaSourceLibraries");
migrationBuilder.DropTable(
"ProgramScheduleDurationItems");
migrationBuilder.DropTable(
"ProgramScheduleFloodItems");
migrationBuilder.DropTable(
"ProgramScheduleMultipleItems");
migrationBuilder.DropTable(
"ProgramScheduleOneItems");
migrationBuilder.DropTable(
"TelevisionMediaCollections");
migrationBuilder.DropTable(
"SimpleMediaCollections");
migrationBuilder.DropTable(
"MediaItems");
migrationBuilder.DropTable(
"Playouts");
migrationBuilder.DropTable(
"PlexMediaSources");
migrationBuilder.DropTable(
"Channels");
migrationBuilder.DropTable(
"ProgramScheduleItems");
migrationBuilder.DropTable(
"MediaSources");
migrationBuilder.DropTable(
"FFmpegProfiles");
migrationBuilder.DropTable(
"MediaCollections");
migrationBuilder.DropTable(
"ProgramSchedules");
migrationBuilder.DropTable(
"Resolutions");
}
}
}

View File

@@ -0,0 +1,879 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
[Migration("20210213155419_MetadataSortTitle")]
partial class MetadataSortTitle
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.ToTable("GenericIntegerIds");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.ToTable("MediaCollectionSummaries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class MetadataSortTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"Metadata_SortTitle",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.Sql(
@"UPDATE MediaItems
SET Metadata_SortTitle = Metadata_Title");
migrationBuilder.Sql(
@"UPDATE MediaItems
SET Metadata_SortTitle = substr(Metadata_Title, 5)
WHERE Metadata_Title LIKE 'the %'");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"Metadata_SortTitle",
"MediaItems");
}
}

View File

@@ -0,0 +1,893 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
[Migration("20210213221040_MediaItemPoster")]
partial class MediaItemPoster
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class MediaItemPoster : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"GenericIntegerIds");
migrationBuilder.DropTable(
"MediaCollectionSummaries");
migrationBuilder.AddColumn<string>(
"Poster",
"MediaItems",
"TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"Poster",
"MediaItems");
migrationBuilder.CreateTable(
"GenericIntegerIds",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { });
migrationBuilder.CreateTable(
"MediaCollectionSummaries",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false),
IsSimple = table.Column<bool>("INTEGER", nullable: false),
ItemCount = table.Column<int>("INTEGER", nullable: false),
Name = table.Column<string>("TEXT", nullable: true)
},
constraints: table => { });
}
}
}

View File

@@ -0,0 +1,997 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
internal class TvContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.GenericIntegerId",
b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
});
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.MediaCollectionSummary",
b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.MediaItemSummary",
b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ConfigElement",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.FFmpegProfile",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaCollection",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaSource",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor",
b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSourceConnection",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSourceLibrary",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramSchedule",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Resolution",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity(
"MediaItemSimpleMediaCollection",
b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.SimpleMediaCollection",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionMediaCollection",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemDuration",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemFlood",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemMultiple",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemOne",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.FFmpegProfile",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne(
"ErsatzTV.Core.Domain.MediaMetadata",
"Metadata",
b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne(
"ErsatzTV.Core.Domain.PlayoutAnchor",
"Anchor",
b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne(
"ErsatzTV.Core.Domain.MediaCollectionEnumeratorState",
"EnumeratorState",
b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey(
"PlayoutProgramScheduleAnchorPlayoutId",
"PlayoutProgramScheduleAnchorProgramScheduleId",
"PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey(
"PlayoutProgramScheduleAnchorPlayoutId",
"PlayoutProgramScheduleAnchorProgramScheduleId",
"PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSourceConnection",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSourceLibrary",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity(
"MediaItemSimpleMediaCollection",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.SimpleMediaCollection",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionMediaCollection",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemDuration",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemFlood",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemMultiple",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemOne",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => { b.Navigation("Playouts"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramSchedule",
b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -105,7 +105,7 @@ namespace ErsatzTV.Infrastructure.Plex
return true;
}
}
catch (Exception ex)
catch (Exception)
{
// ignored
}

View File

@@ -2,18 +2,26 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=DTO/@EntryIndexedValue">DTO</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HDHR/@EntryIndexedValue">HDHR</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SAR/@EntryIndexedValue">SAR</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=anull/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=apad/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=bufsize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=cgop/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Deinterlace/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=deinterlaced/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurettes/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffprobe/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fontfile/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Fprobe/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=genpts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=igndts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Libavfilter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=libx/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=maxrate/@EntryIndexedValue">True</s:Boolean>
@@ -21,5 +29,10 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpegts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxdelay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxpreload/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=nostats/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pixfmt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=playout/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -0,0 +1,28 @@
using System.Threading.Tasks;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers
{
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class PostersController : ControllerBase
{
private readonly IMediator _mediator;
public PostersController(IMediator mediator) => _mediator = mediator;
[HttpGet("/posters/{fileName}")]
public async Task<IActionResult> GetImage(string fileName)
{
Either<BaseError, ImageViewModel> imageContents = await _mediator.Send(new GetImageContents(fileName));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
}
}

View File

@@ -16,11 +16,16 @@
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.2" />
<PackageReference Include="MudBlazor" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.0.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.0.2" />
</ItemGroup>

View File

@@ -1,6 +1,7 @@
using System;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -28,7 +29,7 @@ namespace ErsatzTV.Extensions
private static TvContext Migrate(TvContext context)
{
context.Database.EnsureCreated();
context.Database.Migrate();
return context;
}

View File

@@ -1,25 +0,0 @@
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Models.UI
{
public static class MediaItemExtensions
{
public static string GetDisplayDuration(this MediaItem mediaItem) =>
string.Format(
mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata.Duration);
public static string GetDisplayTitle(this MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow &&
Optional(mediaItem.Metadata.SeasonNumber).IsSome &&
Optional(mediaItem.Metadata.EpisodeNumber).IsSome
? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
public static string GetDisplayMediaType(this MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow
? "TV Show"
: mediaItem.Metadata.MediaType.ToString();
}
}

View File

@@ -1,11 +1,12 @@
@page "/media/sources/local/add"
@using ErsatzTV.Application
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Core.Metadata
@inject NavigationManager NavigationManager
@inject ILogger<LocalMediaSourceEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<div style="max-width: 400px;">
@@ -68,7 +69,13 @@
_messageStore.Clear();
if (_editContext.Validate())
{
var command = new CreateLocalMediaSource(_model.Folder, _model.MediaType, _model.Folder);
var command = new CreateLocalMediaSource(
Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.TrimEnd('=')
.Replace("/", "_")
.Replace("+", "-"),
_model.MediaType,
_model.Folder);
Either<BaseError, MediaSourceViewModel> result = await Mediator.Send(command);
await result.Match(
Left: error =>
@@ -79,10 +86,15 @@
},
Right: async vm =>
{
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id));
NavigationManager.NavigateTo("/media/sources");
if (Locker.LockMediaSource(vm.Id))
{
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, ScanningMode.Default));
NavigationManager.NavigateTo("/media/sources");
}
});
}
}
}

29
ErsatzTV/Pages/Logs.razor Normal file
View File

@@ -0,0 +1,29 @@
@page "/system/logs"
@using ErsatzTV.Application.Logs
@using ErsatzTV.Application.Logs.Queries
@inject IMediator Mediator
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
<HeaderContent>
<MudTh>Timestamp</MudTh>
<MudTh>Level</MudTh>
<MudTh>Message</MudTh>
<MudTh>Properties</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Timestamp">@context.Timestamp</MudTd>
<MudTd DataLabel="Level">@context.Level</MudTd>
<MudTd DataLabel="Message">@context.RenderedMessage</MudTd>
<MudTd DataLabel="Message">@context.Properties</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
@code {
private List<LogEntryViewModel> _logEntries;
protected override async Task OnInitializedAsync() => _logEntries = await Mediator.Send(new GetRecentLogEntries());
}

View File

@@ -1,8 +1,16 @@
@page "/media/collections/{Id:int}/items"
@inject IDbContextFactory<TvContext> DbFactory
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.MediaItems.Queries
@using Unit = LanguageExt.Unit
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject ILogger<MediaCollectionItemsEditor> Logger
@inject ISnackbar Snackbar
<MudTable Hover="true" Items="_mediaCollection.Items">
<MudTable Hover="true" Items="_collectionItems">
<ToolBarContent>
<MudText Typo="Typo.h6">@_mediaCollection.Name Media Items</MudText>
</ToolBarContent>
@@ -13,20 +21,20 @@
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Source">@context.Source.Name</MudTd>
<MudTd DataLabel="Type">@context.GetDisplayMediaType()</MudTd>
<MudTd DataLabel="Title">@context.GetDisplayTitle()</MudTd>
<MudTd DataLabel="Duration">@context.Metadata.Duration.ToString(@"hh\:mm\:ss\.fff")</MudTd>
<MudTd DataLabel="Source">@context.Source</MudTd>
<MudTd DataLabel="Type">@context.MediaType</MudTd>
<MudTd DataLabel="Title">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
<PagerContent>
@if (_mediaCollection.Items.Count > 0)
@if (_collectionItems.Any())
{
<MudTablePager/>
}
</PagerContent>
</MudTable>
<MudTable @ref="_table" Hover="true" ServerData="@(new Func<TableState, Task<TableData<MediaItem>>>(ServerReload))" Class="mt-8">
<MudTable @ref="_table" Hover="true" ServerData="@(new Func<TableState, Task<TableData<MediaItemSearchResultViewModel>>>(ServerReload))" Class="mt-8">
<ToolBarContent>
<MudText Typo="Typo.h6">All Media Items</MudText>
<MudToolBarSpacer/>
@@ -41,10 +49,10 @@
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Source">@context.Source.Name</MudTd>
<MudTd DataLabel="Type">@context.GetDisplayMediaType()</MudTd>
<MudTd DataLabel="Title">@context.GetDisplayTitle()</MudTd>
<MudTd DataLabel="Duration">@context.GetDisplayDuration()</MudTd>
<MudTd DataLabel="Source">@context.Source</MudTd>
<MudTd DataLabel="Type">@context.MediaType</MudTd>
<MudTd DataLabel="Title">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
@@ -59,32 +67,27 @@
[Parameter]
public int Id { get; set; }
private SimpleMediaCollection _mediaCollection;
private MediaCollectionViewModel _mediaCollection;
private IEnumerable<MediaItemSearchResultViewModel> _collectionItems;
protected override async Task OnParametersSetAsync() => await LoadMediaCollectionAsync();
private List<int> _mediaItemIds;
private IEnumerable<MediaItem> _pagedData;
private MudTable<MediaItem> _table;
private IEnumerable<MediaItemSearchResultViewModel> _pagedData;
private MudTable<MediaItemSearchResultViewModel> _table;
private int _totalItems;
private string _searchString;
private async Task<TableData<MediaItem>> ServerReload(TableState state)
private async Task<TableData<MediaItemSearchResultViewModel>> ServerReload(TableState state)
{
await using TvContext context = DbFactory.CreateDbContext();
IQueryable<MediaItem> data = from c in context.MediaItems.Include(c => c.Source) select c;
if (!string.IsNullOrEmpty(_searchString))
{
data = data.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{_searchString}%"));
}
List<MediaItemSearchResultViewModel> data = await Mediator.Send(new SearchAllMediaItems(_searchString));
_mediaItemIds = data.Map(c => c.Id).ToList();
_totalItems = data.Count();
_totalItems = data.Count;
_pagedData = data.OrderBy(c => c.Id).Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray();
return new TableData<MediaItem> { TotalItems = _totalItems, Items = _pagedData };
return new TableData<MediaItemSearchResultViewModel> { TotalItems = _totalItems, Items = _pagedData };
}
private async Task OnSearch(string text)
@@ -95,32 +98,24 @@
private async Task AddResultsAsync()
{
await using TvContext context = DbFactory.CreateDbContext();
SimpleMediaCollection mediaCollection = await context.SimpleMediaCollections.FindAsync(_mediaCollection.Id);
await context.Entry(mediaCollection).Collection(cg => cg.Items).LoadAsync();
IEnumerable<int> existingMediaItems = _mediaCollection.Items.Select(c => c.Id);
IQueryable<MediaItem> mediaItemsToAdd = from c in context.MediaItems
where _mediaItemIds.Contains(c.Id) && !existingMediaItems.Contains(c.Id)
select c;
foreach (MediaItem mediaItem in mediaItemsToAdd)
{
mediaCollection.Items.Add(mediaItem);
}
context.MediaCollections.Update(mediaCollection);
await context.SaveChangesAsync();
await LoadMediaCollectionAsync();
Either<BaseError, Unit> result = await Mediator.Send(new AddItemsToSimpleMediaCollection(Id, _mediaItemIds));
await result.Match(
async _ => await LoadMediaCollectionAsync(),
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error adding items to media collection: {Error}", error.Value);
return Task.CompletedTask;
});
}
private async Task LoadMediaCollectionAsync()
{
await using TvContext context = DbFactory.CreateDbContext();
_mediaCollection = await context.SimpleMediaCollections
.AsNoTracking()
.Include(cg => cg.Items)
.ThenInclude(c => c.Source)
.FirstAsync(cg => cg.Id == Id);
Option<Tuple<MediaCollectionViewModel, List<MediaItemSearchResultViewModel>>> maybeResult =
await Mediator.Send(new GetSimpleMediaCollectionWithItemsById(Id));
maybeResult.Match(
result => (_mediaCollection, _collectionItems) = result,
() => NavigationManager.NavigateTo("404"));
}
}

View File

@@ -1,18 +0,0 @@
@page "/media/items"
@inject IDbContextFactory<TvContext> DbFactory
<MudTabs Elevation="1">
<MudTabPanel Text="TV Shows">
<MediaItemTable MediaType="@MediaType.TvShow"/>
</MudTabPanel>
<MudTabPanel Text="Movies">
<MediaItemTable MediaType="@MediaType.Movie"/>
</MudTabPanel>
<MudTabPanel Text="Other">
<MediaItemTable MediaType="@MediaType.Other"/>
</MudTabPanel>
</MudTabs>
@code {
}

View File

@@ -0,0 +1,8 @@
@page "/media/movies/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.Movie"/>
@code {
}

View File

@@ -0,0 +1,8 @@
@page "/media/other/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.Other"/>
@code {
}

View File

@@ -0,0 +1,8 @@
@page "/media/tv/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.TvShow"/>
@code {
}

View File

@@ -65,7 +65,7 @@
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value);
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving playout: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/playouts"));

View File

@@ -28,7 +28,7 @@
<RowTemplate>
<MudTd DataLabel="Start Time">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic")
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudText>
</MudTd>
<MudTd DataLabel="Media Collection">
@@ -73,7 +73,7 @@
<MudSelectItem Value="@startType">@startType</MudSelectItem>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Start Time" @bind-Value="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudAutocomplete Class="mt-3" T="MediaCollectionViewModel" Label="Media Collection" @bind-value="_selectedItem.MediaCollection" SearchFunc="@SearchMediaCollections" ToStringFunc="@(c => c?.Name)"/>
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
@@ -82,7 +82,7 @@
}
</MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTextField Class="mt-3" Label="Playout Duration" @bind-Value="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<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>

View File

@@ -57,7 +57,7 @@
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
@(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic")
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudTd>
<MudTd DataLabel="Media Collection">@context.MediaCollection.Name</MudTd>
<MudTd DataLabel="Media Collection">@context.PlayoutMode</MudTd>

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@@ -25,6 +26,7 @@ namespace ErsatzTV
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(Configuration)
.Enrich.FromLogContext()
.WriteTo.SQLite(FileSystemLayout.LogDatabasePath, retentionPeriod: TimeSpan.FromDays(1))
.CreateLogger();
try
@@ -54,4 +56,4 @@ namespace ErsatzTV
.UseKestrel(options => options.AddServerHeader = false))
.UseSerilog();
}
}
}

View File

@@ -63,14 +63,15 @@ namespace ErsatzTV.Services
TryCompletePlexPinFlow pinRequest => CompletePinFlow(pinRequest, cancellationToken),
SynchronizePlexMediaSources sourcesRequest => SynchronizeSources(
sourcesRequest,
cancellationToken)
cancellationToken),
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
};
await requestTask;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process poll for Plex auth token request");
_logger.LogWarning(ex, "Failed to process plex background service request");
}
}
}

View File

@@ -6,6 +6,8 @@ using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -16,15 +18,18 @@ namespace ErsatzTV.Services
public class SchedulerService : IHostedService
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IServiceScopeFactory _serviceScopeFactory;
private Timer _timer;
public SchedulerService(
IServiceScopeFactory serviceScopeFactory,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
{
_serviceScopeFactory = serviceScopeFactory;
_channel = channel;
_entityLocker = entityLocker;
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -75,7 +80,12 @@ namespace ErsatzTV.Services
foreach (int mediaSourceId in localMediaSourceIds)
{
await _channel.WriteAsync(new ScanLocalMediaSource(mediaSourceId), cancellationToken);
if (_entityLocker.LockMediaSource(mediaSourceId))
{
await _channel.WriteAsync(
new ScanLocalMediaSource(mediaSourceId, ScanningMode.Default),
cancellationToken);
}
}
}
}

View File

@@ -62,6 +62,7 @@ namespace ErsatzTV.Services
RefreshMediaItemMetadata => "metadata",
RefreshMediaItemStatistics => "statistics",
RefreshMediaItemCollections => "collections",
RefreshMediaItemPoster => "poster",
_ => ""
};

View File

@@ -1,16 +1,20 @@
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.MediaSources.Queries
@using ErsatzTV.Core.Metadata
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudTable Hover="true" Items="_mediaSources">
<MudTable Hover="true" Items="_mediaSources" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6">Local Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col style="width: 60px;"/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Folder</MudTh>
@@ -19,7 +23,29 @@
<RowTemplate>
<MudTd DataLabel="Folder">@context.Folder</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteMediaSource(context))"></MudIconButton>
<div style="align-items: center; display: flex;">
@if (Locker.IsMediaSourceLocked(context.Id))
{
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
</div>
}
else
{
<MudTooltip Text="Refresh All Metadata">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@Locker.IsMediaSourceLocked(context.Id)"
OnClick="@(_ => RefreshAllMetadata(context))">
</MudIconButton>
</MudTooltip>
}
<MudTooltip Text="Delete Media Source">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@Locker.IsMediaSourceLocked(context.Id)"
OnClick="@(_ => DeleteMediaSource(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@@ -30,6 +56,9 @@
@code {
private IList<LocalMediaSourceViewModel> _mediaSources;
protected override void OnInitialized() =>
Locker.OnMediaSourceChanged += LockChanged;
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
private async Task LoadMediaSources() =>
@@ -42,7 +71,7 @@
var parameters = new DialogParameters
{
{ "EntityType", "media source" },
{ "EntityName", mediaSource.Name },
{ "EntityName", mediaSource.Folder },
{ "DetailText", $"This media source contains {count} media items." },
{ "DetailHighlight", count.ToString() }
};
@@ -57,4 +86,18 @@
}
}
private async Task RefreshAllMetadata(LocalMediaSourceViewModel mediaSource)
{
if (Locker.LockMediaSource(mediaSource.Id))
{
await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, ScanningMode.RescanAll));
StateHasChanged();
}
}
private void LockChanged(object sender, EventArgs e) =>
InvokeAsync(StateHasChanged);
void IDisposable.Dispose() => Locker.OnMediaSourceChanged -= LockChanged;
}

View File

@@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@using System.Reflection
@inherits LayoutComponentBase
<MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/>
@@ -12,7 +13,7 @@
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
<MudDivider Vertical="true" FlexItem="true" DividerType="DividerType.Middle" Class="mx-4 my-5" />
<MudDivider Vertical="true" FlexItem="true" DividerType="DividerType.Middle" Class="mx-4 my-5"/>
<MudTooltip Text="Discord">
<MudIconButton Icon="fab fa-discord" Color="Color.Inherit" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
</MudTooltip>
@@ -27,22 +28,32 @@
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/sources">Media Sources</MudNavLink>
<MudNavLink Href="/media/items">Media Items</MudNavLink>
<MudNavLink Href="/media/tv/items">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies/items">Movies</MudNavLink>
<MudNavLink Href="/media/other/items">Other Items</MudNavLink>
<MudNavLink Href="/media/collections">Media Collections</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>
<MudNavLink Href="/playouts">Playouts</MudNavLink>
<MudNavLink Href="/system/logs">Logs</MudNavLink>
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
<MudText Typo="Typo.body2">ErsatzTV Version</MudText>
<MudText Typo="Typo.body2" Color="Color.Info">@InfoVersion</MudText>
</MudContainer>
</MudNavMenu>
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
@code {
bool _drawerOpen = true;
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private bool _drawerOpen = true;
private void DrawerToggle() => _drawerOpen = !_drawerOpen;

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