Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be729c10e | ||
|
|
0aac702853 | ||
|
|
3f406ac556 | ||
|
|
454e2edf7c | ||
|
|
b3f4fa8c23 | ||
|
|
a6496db58d | ||
|
|
3eed79b5e1 | ||
|
|
79bfba6428 | ||
|
|
9f6d4114a6 | ||
|
|
9809c60924 | ||
|
|
16072fed1c | ||
|
|
3fb6da0754 | ||
|
|
24cdf6295f | ||
|
|
c1b41e2865 | ||
|
|
d249e95f12 | ||
|
|
efae005447 | ||
|
|
cead787c55 | ||
|
|
77a69af1a8 | ||
|
|
8fea24a3a5 | ||
|
|
6b44873474 |
@@ -79,3 +79,7 @@ indent_size=2
|
||||
indent_style=space
|
||||
indent_size=4
|
||||
tab_width=4
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Publish docs via GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CUSTOM_DOMAIN: ersatztv.org
|
||||
@@ -50,7 +50,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
AudioCodec = request.AudioCodec,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeLoudness= request.NormalizeLoudness,
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.HDHR.Commands
|
||||
{
|
||||
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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.HDHR.Commands
|
||||
{
|
||||
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateHDHRTunerCount request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
|
||||
Optional(request.TunerCount)
|
||||
.Filter(tc => tc > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
||||
.AsTask();
|
||||
|
||||
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
|
||||
_configElementRepository.Get(key).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = value;
|
||||
return _configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement { Key = key.Key, Value = value };
|
||||
return _configElementRepository.Add(ce);
|
||||
}).ToUnit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.HDHR.Queries
|
||||
{
|
||||
public record GetHDHRTunerCount : IRequest<int>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.HDHR.Queries
|
||||
{
|
||||
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
|
||||
.Map(result => result.IfNone(2));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs
|
||||
{
|
||||
public record LogEntryViewModel(
|
||||
int Id,
|
||||
DateTime Timestamp,
|
||||
string Level,
|
||||
LogEventLevel Level,
|
||||
string Exception,
|
||||
string RenderedMessage,
|
||||
string Properties);
|
||||
string Message);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
|
||||
new(
|
||||
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
|
||||
{
|
||||
string message = logEntry.RenderedMessage;
|
||||
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
|
||||
{
|
||||
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
|
||||
{
|
||||
var token = $"{{{property.Key}}}";
|
||||
if (message.Contains(token))
|
||||
{
|
||||
message = message.Replace(token, property.Value.ToString());
|
||||
}
|
||||
|
||||
var destructureToken = $"{{@{property.Key}}}";
|
||||
if (message.Contains(destructureToken))
|
||||
{
|
||||
message = message.Replace(destructureToken, property.Value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
|
||||
{
|
||||
level = LogEventLevel.Debug;
|
||||
}
|
||||
|
||||
return new LogEntryViewModel(
|
||||
logEntry.Id,
|
||||
logEntry.Timestamp,
|
||||
logEntry.Level,
|
||||
level,
|
||||
logEntry.Exception,
|
||||
logEntry.RenderedMessage,
|
||||
logEntry.Properties);
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public record GetAllLanguageCodes : IRequest<List<CultureInfo>>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<CultureInfo>();
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
|
||||
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
|
||||
foreach (string code in allLanguageCodes)
|
||||
{
|
||||
Option<CultureInfo> maybeCulture = allCultures.Find(
|
||||
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
|
||||
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,24 +8,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
{
|
||||
int LibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
bool Rescan { get; }
|
||||
}
|
||||
|
||||
public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
public bool Rescan => false;
|
||||
}
|
||||
|
||||
public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
public bool Rescan => false;
|
||||
}
|
||||
|
||||
public record ForceRescanLocalLibrary(int LibraryId) : IScanLocalLibrary
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
public bool Rescan => true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -16,13 +17,13 @@ using Unit = LanguageExt.Unit;
|
||||
namespace ErsatzTV.Application.MediaSources.Commands
|
||||
{
|
||||
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>,
|
||||
IRequestHandler<ForceRescanLocalLibrary, Either<BaseError, string>>
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<ScanLocalLibraryHandler> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
@@ -42,13 +44,10 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceRescanLocalLibrary request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceScanLocalLibrary request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
@@ -65,47 +64,63 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, bool rescan) = parameters;
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
|
||||
|
||||
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
|
||||
for (var i = 0; i < localLibrary.Paths.Count; i++)
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
LibraryPath libraryPath = localLibrary.Paths[i];
|
||||
|
||||
DateTimeOffset effectiveLastScan = rescan ? DateTimeOffset.MinValue : lastScan;
|
||||
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
|
||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
foreach (LibraryPath libraryPath in localLibrary.Paths)
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
{
|
||||
switch (localLibrary.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
|
||||
await _movieFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
|
||||
await _televisionFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.MusicVideos:
|
||||
await _musicVideoFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
|
||||
await _musicVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
lastScan,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(libraryPath);
|
||||
}
|
||||
|
||||
localLibrary.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(localLibrary);
|
||||
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax));
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of library {Name}",
|
||||
localLibrary.Name);
|
||||
}
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
|
||||
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
return Unit.Default;
|
||||
@@ -117,8 +132,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
(library, ffprobePath) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
request.ForceScan,
|
||||
request.Rescan));
|
||||
request.ForceScan));
|
||||
|
||||
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
IScanLocalLibrary request) =>
|
||||
@@ -133,6 +147,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan, bool Rescan);
|
||||
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
|
||||
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
|
||||
{
|
||||
int PlexLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
||||
@@ -31,6 +32,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||
{
|
||||
@@ -38,6 +40,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_entityLocker = entityLocker;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
}
|
||||
|
||||
parameters.Library.LastScan = DateTime.UtcNow;
|
||||
await _mediaSourceRepository.Update(parameters.Library);
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
@@ -14,6 +15,7 @@ namespace ErsatzTV.Application.Search.Commands
|
||||
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
@@ -22,18 +24,22 @@ namespace ErsatzTV.Application.Search.Commands
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
await _searchIndex.Initialize(_localFileSystem);
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
@@ -41,7 +47,7 @@ namespace ErsatzTV.Application.Search.Commands
|
||||
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
|
||||
await _searchIndex.Rebuild(itemIds);
|
||||
await _searchIndex.Rebuild(_searchRepository, itemIds);
|
||||
|
||||
Option<ConfigElement> maybeVersion =
|
||||
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
|
||||
|
||||
@@ -14,10 +14,12 @@ using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Metadata
|
||||
{
|
||||
@@ -84,7 +86,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsLeft.Should().BeTrue();
|
||||
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
|
||||
@@ -107,7 +111,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -146,7 +152,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -186,7 +194,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -230,7 +240,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -277,7 +289,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -324,7 +338,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -370,7 +386,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -412,7 +430,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -448,7 +468,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -486,7 +508,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -513,7 +537,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
DateTimeOffset.MinValue,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -531,6 +557,8 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ISearchRepository>().Object,
|
||||
new Mock<IMediator>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
@@ -6,6 +7,7 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public DateTime? LastScan { get; set; }
|
||||
|
||||
public int LibraryId { get; set; }
|
||||
public Library Library { get; set; }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
|
||||
@@ -68,12 +68,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{audioStreamIndex}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
bool isHardwareDecode = acceleration switch
|
||||
|
||||
@@ -22,6 +22,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
@@ -46,7 +47,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FFmpegProfile ffmpegProfile,
|
||||
MediaVersion version,
|
||||
MediaStream videoStream,
|
||||
MediaStream audioStream,
|
||||
Option<MediaStream> audioStream,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
@@ -113,10 +114,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
if (audioStream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
audioStream.IfSome(
|
||||
stream =>
|
||||
{
|
||||
if (stream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
});
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = version.Duration;
|
||||
|
||||
@@ -199,6 +199,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
"-c", "copy",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
// "-avoid_negative_ts", "make_zero"
|
||||
};
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
@@ -353,12 +354,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
|
||||
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
|
||||
{
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{audioStreamIndex}";
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
|
||||
maybeFilter.IfSome(
|
||||
filter =>
|
||||
{
|
||||
|
||||
@@ -30,14 +30,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset now)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
version,
|
||||
videoStream,
|
||||
audioStream,
|
||||
maybeAudioStream,
|
||||
start,
|
||||
now);
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -76,18 +76,18 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
@@ -25,8 +26,17 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
|
||||
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
|
||||
{
|
||||
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
|
||||
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Channel {Number} is HLS with no preferred language; using all audio streams",
|
||||
channel.Number);
|
||||
return None;
|
||||
}
|
||||
|
||||
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
|
||||
|
||||
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegStreamSelector
|
||||
{
|
||||
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
|
||||
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
|
||||
Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
ShowMetadata GetFallbackMetadataForShow(string showFolder);
|
||||
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
|
||||
MovieMetadata GetFallbackMetadata(Movie movie);
|
||||
MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo);
|
||||
string GetSortTitle(string title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalMetadataProvider
|
||||
{
|
||||
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
||||
Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath);
|
||||
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
|
||||
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
|
||||
Task<bool> RefreshFallbackMetadata(Movie movie);
|
||||
Task<bool> RefreshFallbackMetadata(Episode episode);
|
||||
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
|
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface IMovieFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface IMusicVideoFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ITelevisionFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<LocalLibrary>> GetLocal(int libraryId);
|
||||
Task<List<Library>> GetAll();
|
||||
Task<Unit> UpdateLastScan(Library library);
|
||||
Task<Unit> UpdateLastScan(LibraryPath libraryPath);
|
||||
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
|
||||
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
||||
Task<int> CountMediaItemsByPath(int libraryPathId);
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<MediaItem>> Get(int id);
|
||||
Task<List<MediaItem>> GetAll();
|
||||
Task<bool> Update(MediaItem mediaItem);
|
||||
Task<List<string>> GetAllLanguageCodes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMusicVideoRepository
|
||||
{
|
||||
Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
|
||||
LibraryPath libraryPath,
|
||||
string filePath,
|
||||
MusicVideoMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
|
||||
|
||||
@@ -12,5 +12,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
|
||||
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
|
||||
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
|
||||
public Task<List<string>> GetLanguagesForShow(Show show);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search
|
||||
{
|
||||
public interface ISearchIndex
|
||||
public interface ISearchIndex : IDisposable
|
||||
{
|
||||
public int Version { get; }
|
||||
Task<bool> Initialize();
|
||||
Task<Unit> Rebuild(List<int> itemIds);
|
||||
Task<Unit> AddItems(List<MediaItem> items);
|
||||
Task<Unit> UpdateItems(List<MediaItem> items);
|
||||
Task<bool> Initialize(ILocalFileSystem localFileSystem);
|
||||
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
|
||||
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
void Commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
public MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo)
|
||||
{
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new MusicVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Fallback,
|
||||
Title = fileName ?? path
|
||||
};
|
||||
|
||||
return fileName != null ? GetMusicVideoMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
public string GetSortTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -112,6 +125,30 @@ namespace ErsatzTV.Core.Metadata
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private MusicVideoMetadata GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"^(.*?) - (.*?).\w+$";
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Artist = match.Groups[1].Value;
|
||||
metadata.Title = match.Groups[2].Value;
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public record LibraryScanProgress(int LibraryId, decimal Progress) : INotification;
|
||||
}
|
||||
@@ -70,23 +70,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath)
|
||||
{
|
||||
string nfoFileName = Path.ChangeExtension(filePath, "nfo");
|
||||
Option<MusicVideoMetadata> maybeMetadata = None;
|
||||
if (_localFileSystem.FileExists(nfoFileName))
|
||||
{
|
||||
maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
return maybeMetadata.Map(
|
||||
metadata =>
|
||||
{
|
||||
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
|
||||
LoadMovieMetadata(movie, nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
@@ -109,7 +92,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
LoadMusicVideoMetadata(nfoFileName).Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(musicVideo, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
() => RefreshFallbackMetadata(musicVideo)));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
|
||||
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
|
||||
@@ -117,6 +100,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
public Task<bool> RefreshFallbackMetadata(Episode episode) =>
|
||||
ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
|
||||
ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo));
|
||||
|
||||
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
|
||||
|
||||
|
||||
@@ -164,7 +164,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
if (videoStream != null)
|
||||
{
|
||||
version.SampleAspectRatio = videoStream.sample_aspect_ratio;
|
||||
version.SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.sample_aspect_ratio)
|
||||
? "1:1"
|
||||
: videoStream.sample_aspect_ratio;
|
||||
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
|
||||
@@ -10,9 +10,11 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Seq = LanguageExt.Seq;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -21,8 +23,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MovieFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -32,6 +36,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IMediator mediator,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
{
|
||||
@@ -39,19 +45,27 @@ namespace ErsatzTV.Core.Metadata
|
||||
_movieRepository = movieRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
|
||||
{
|
||||
@@ -60,7 +74,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(movieFolder)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
@@ -99,11 +118,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
@@ -124,6 +143,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -20,8 +22,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MusicVideoFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public MusicVideoFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -30,7 +34,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IMediator mediator,
|
||||
ILogger<MusicVideoFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
@@ -41,48 +47,52 @@ namespace ErsatzTV.Core.Metadata
|
||||
_localFileSystem = localFileSystem;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(movieFolder)
|
||||
string musicVideoFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(
|
||||
f => !ExtraFiles.Any(
|
||||
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
|
||||
.Filter(f => f.Contains(" - "))
|
||||
.ToList();
|
||||
|
||||
if (allFiles.Count == 0)
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
continue;
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
if (_localFileSystem.GetLastWriteTime(movieFolder) < lastScan)
|
||||
if (_localFileSystem.GetLastWriteTime(musicVideoFolder) < lastScan)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -90,22 +100,22 @@ namespace ErsatzTV.Core.Metadata
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playouts
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
|
||||
await FindOrCreateMusicVideo(libraryPath, file)
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
|
||||
await maybeMusicVideo.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
@@ -126,37 +136,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> FindOrCreateMusicVideo(
|
||||
LibraryPath libraryPath,
|
||||
string filePath)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeMetadata = await _localMetadataProvider.GetMetadataForMusicVideo(filePath);
|
||||
return await maybeMetadata.Match(
|
||||
async metadata =>
|
||||
{
|
||||
Option<MusicVideo> maybeMusicVideo =
|
||||
await _musicVideoRepository.GetByMetadata(libraryPath, metadata);
|
||||
return await maybeMusicVideo.Match(
|
||||
musicVideo =>
|
||||
Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
new MediaItemScanResult<MusicVideo>(musicVideo))
|
||||
.AsTask(),
|
||||
async () => await _musicVideoRepository.Add(libraryPath, filePath, metadata));
|
||||
},
|
||||
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
BaseError.New("Unable to locate metadata for music video")).AsTask());
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
|
||||
MediaItemScanResult<MusicVideo> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
MusicVideo musicVideo = result.Item;
|
||||
return await LocateNfoFile(musicVideo).Match<Task<Either<BaseError, MediaItemScanResult<MusicVideo>>>>(
|
||||
await LocateNfoFile(musicVideo).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
|
||||
@@ -172,11 +162,21 @@ namespace ErsatzTV.Core.Metadata
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
BaseError.New("Unable to locate metadata for music video")).AsTask());
|
||||
async () =>
|
||||
{
|
||||
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
|
||||
{
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -10,8 +10,10 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -20,7 +22,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
@@ -31,6 +35,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IMediator mediator,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
@@ -42,14 +48,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
DateTimeOffset lastScan,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
@@ -62,6 +74,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
decimal percentCompletion = (decimal) allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
@@ -73,11 +89,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await ScanSeasons(
|
||||
@@ -111,6 +127,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,21 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public abstract class PlexLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexLibraryScanner> _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
protected PlexLibraryScanner(IMetadataRepository metadataRepository) =>
|
||||
protected PlexLibraryScanner(IMetadataRepository metadataRepository, ILogger<PlexLibraryScanner> logger)
|
||||
{
|
||||
_metadataRepository = metadataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Unit> UpdateArtworkIfNeeded(
|
||||
Domain.Metadata existingMetadata,
|
||||
@@ -27,6 +32,8 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeIncomingArtwork.Match(
|
||||
async incomingArtwork =>
|
||||
{
|
||||
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
|
||||
|
||||
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
|
||||
@@ -7,30 +7,38 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public PlexMovieLibraryScanner(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IMovieRepository movieRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IMediator mediator,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_movieRepository = movieRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,6 +57,9 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
foreach (PlexMovie incoming in movieEntries)
|
||||
{
|
||||
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
||||
@@ -61,11 +72,13 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
@@ -81,6 +94,8 @@ namespace ErsatzTV.Core.Plex
|
||||
var movieKeys = movieEntries.Map(s => s.Key).ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
|
||||
},
|
||||
error =>
|
||||
{
|
||||
@@ -92,6 +107,7 @@ namespace ErsatzTV.Core.Plex
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -113,6 +129,11 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeStatistics.Match(
|
||||
async mediaVersion =>
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Statistics",
|
||||
existingVersion.MediaFiles.Head().Path);
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
@@ -135,6 +156,11 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Metadata",
|
||||
existing.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
|
||||
@@ -7,17 +7,21 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
|
||||
{
|
||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public PlexTelevisionLibraryScanner(
|
||||
@@ -25,13 +29,17 @@ namespace ErsatzTV.Core.Plex
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IMediator mediator,
|
||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_televisionRepository = televisionRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,6 +58,9 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
foreach (PlexShow incoming in showEntries)
|
||||
{
|
||||
decimal percentCompletion = (decimal) showEntries.IndexOf(incoming) / showEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
|
||||
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
|
||||
@@ -61,11 +72,13 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
|
||||
@@ -85,6 +98,9 @@ namespace ErsatzTV.Core.Plex
|
||||
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
},
|
||||
error =>
|
||||
|
||||
@@ -62,6 +62,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
|
||||
new { library.LastScan, library.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> UpdateLastScan(LibraryPath libraryPath) => _dbConnection.ExecuteAsync(
|
||||
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
|
||||
new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
|
||||
|
||||
public Task<List<LibraryPath>> GetLocalPaths(int libraryId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
@@ -11,10 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class MediaItemRepository : IMediaItemRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<Option<MediaItem>> Get(int id)
|
||||
{
|
||||
@@ -38,5 +44,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
context.MediaItems.Update(mediaItem);
|
||||
return await context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<List<string>> GetAllLanguageCodes() =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT LanguageCode FROM
|
||||
(SELECT Language AS LanguageCode
|
||||
FROM MediaStream WHERE Language IS NOT NULL
|
||||
UNION ALL SELECT PreferredLanguageCode AS LanguageCode
|
||||
FROM Channel WHERE PreferredLanguageCode IS NOT NULL)
|
||||
GROUP BY LanguageCode
|
||||
ORDER BY COUNT(LanguageCode) DESC")
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,81 +25,35 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<int> maybeId = await dbContext.MusicVideoMetadata
|
||||
.Where(s => s.Artist == metadata.Artist && s.Title == metadata.Title && s.Year == metadata.Year)
|
||||
.Where(s => s.MusicVideo.LibraryPathId == libraryPath.Id)
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(sm => sm.MusicVideoId);
|
||||
|
||||
return await maybeId.Match(
|
||||
id =>
|
||||
{
|
||||
return dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artwork)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Tags)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(mv => mv.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.OrderBy(mv => mv.Id)
|
||||
.SingleOrDefaultAsync(mv => mv.Id == id)
|
||||
.Map(Optional);
|
||||
},
|
||||
() => Option<MusicVideo>.None.AsTask());
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
|
||||
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
|
||||
LibraryPath libraryPath,
|
||||
string filePath,
|
||||
MusicVideoMetadata metadata)
|
||||
string path)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<MusicVideo> maybeExisting = await dbContext.MusicVideos
|
||||
.AsNoTracking()
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artwork)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Tags)
|
||||
.Include(mv => mv.MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Studios)
|
||||
.Include(mv => mv.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mv => mv.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
try
|
||||
{
|
||||
metadata.DateAdded = DateTime.UtcNow;
|
||||
metadata.Genres ??= new List<Genre>();
|
||||
metadata.Tags ??= new List<Tag>();
|
||||
metadata.Studios ??= new List<Studio>();
|
||||
var musicVideo = new MusicVideo
|
||||
{
|
||||
LibraryPathId = libraryPath.Id,
|
||||
MusicVideoMetadata = new List<MusicVideoMetadata> { metadata },
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = filePath }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await dbContext.MusicVideos.AddAsync(musicVideo);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(musicVideo).Reference(s => s.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
|
||||
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
return await maybeExisting.Match(
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<MusicVideo>>(
|
||||
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddMusicVideo(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath) =>
|
||||
@@ -177,5 +131,40 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.SingleOrDefaultAsync(m => m.Id == musicVideoId)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> AddMusicVideo(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var musicVideo = new MusicVideo
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await dbContext.MusicVideos.AddAsync(musicVideo);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(musicVideo).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.OrderBy(mi => mi.Id)
|
||||
.SingleOrDefaultAsync(mi => mi.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -128,5 +138,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.OfType<MediaItem>()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<string>> GetLanguagesForShow(Show show) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@"SELECT DISTINCT Language
|
||||
FROM MediaStream
|
||||
INNER JOIN MediaVersion MV on MediaStream.MediaVersionId = MV.Id
|
||||
INNER JOIN Episode E on MV.EpisodeId = E.Id
|
||||
INNER JOIN Season S on E.SeasonId = S.Id
|
||||
WHERE S.ShowId = @ShowId",
|
||||
new { ShowId = show.Id }).Map(result => result.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_LibraryPath_LastScan : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
"LastScan",
|
||||
"LibraryPath",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropColumn(
|
||||
"LastScan",
|
||||
"LibraryPath");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Update_LibraryPathLastScan_LibraryLastScan : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan =
|
||||
(SELECT LastScan FROM Library L
|
||||
INNER JOIN LocalLibrary LL on L.Id = LL.Id
|
||||
WHERE LibraryPath.LibraryId = L.Id)");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Update_MediaVersion_SampleAspectRatio11 : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(
|
||||
@"UPDATE MediaVersion SET SampleAspectRatio = '1:1' where SampleAspectRatio is null");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ using Query = Lucene.Net.Search.Query;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Search
|
||||
{
|
||||
public class SearchIndex : ISearchIndex
|
||||
public sealed class SearchIndex : ISearchIndex
|
||||
{
|
||||
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
|
||||
|
||||
@@ -40,111 +40,90 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private const string JumpLetterField = "jump_letter";
|
||||
private const string ReleaseDateField = "release_date";
|
||||
private const string StudioField = "studio";
|
||||
private const string LanguageField = "language";
|
||||
|
||||
private const string MovieType = "movie";
|
||||
private const string ShowType = "show";
|
||||
private const string MusicVideoType = "music_video";
|
||||
|
||||
private static bool _isRebuilding;
|
||||
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<SearchIndex> _logger;
|
||||
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private FSDirectory _directory;
|
||||
private IndexWriter _writer;
|
||||
|
||||
public SearchIndex(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchRepository searchRepository,
|
||||
ILogger<SearchIndex> logger)
|
||||
public SearchIndex(ILogger<SearchIndex> logger) => _logger = logger;
|
||||
|
||||
public int Version => 4;
|
||||
|
||||
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_searchRepository = searchRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
public int Version => 2;
|
||||
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
|
||||
{ OpenMode = OpenMode.CREATE_OR_APPEND };
|
||||
_writer = new IndexWriter(_directory, indexConfig);
|
||||
|
||||
public Task<bool> Initialize()
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public async Task<Unit> Rebuild(List<int> itemIds)
|
||||
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
|
||||
{
|
||||
_isRebuilding = true;
|
||||
|
||||
await Initialize();
|
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (int id in itemIds)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
|
||||
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
|
||||
if (maybeMediaItem.IsSome)
|
||||
{
|
||||
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
|
||||
switch (mediaItem)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie, writer);
|
||||
UpdateMovie(movie);
|
||||
break;
|
||||
case Show show:
|
||||
UpdateShow(show, writer);
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
UpdateMusicVideo(musicVideo, writer);
|
||||
UpdateMusicVideo(musicVideo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isRebuilding = false;
|
||||
|
||||
_writer.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items);
|
||||
public Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items) =>
|
||||
UpdateItems(searchRepository, items);
|
||||
|
||||
public Task<Unit> UpdateItems(List<MediaItem> items)
|
||||
public async Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items)
|
||||
{
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (MediaItem item in items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie, writer);
|
||||
UpdateMovie(movie);
|
||||
break;
|
||||
case Show show:
|
||||
UpdateShow(show, writer);
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
UpdateMusicVideo(musicVideo, writer);
|
||||
UpdateMusicVideo(musicVideo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> RemoveItems(List<int> ids)
|
||||
{
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
writer.DeleteDocuments(new Term(IdField, id.ToString()));
|
||||
_writer.DeleteDocuments(new Term(IdField, id.ToString()));
|
||||
}
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
@@ -152,14 +131,12 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
|
||||
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "")
|
||||
{
|
||||
if (_isRebuilding ||
|
||||
string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
|
||||
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
|
||||
{
|
||||
return new SearchResult(new List<SearchItem>(), 0).AsTask();
|
||||
}
|
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
using var reader = DirectoryReader.Open(dir);
|
||||
using DirectoryReader reader = _writer.GetReader(true);
|
||||
var searcher = new IndexSearcher(reader);
|
||||
int hitsLimit = skip + limit;
|
||||
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
@@ -182,6 +159,14 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
return searchResult.AsTask();
|
||||
}
|
||||
|
||||
public void Commit() => _writer.Commit();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_writer?.Dispose();
|
||||
_directory?.Dispose();
|
||||
}
|
||||
|
||||
private static Option<SearchPageMap> GetSearchPageMap(
|
||||
IndexSearcher searcher,
|
||||
Query query,
|
||||
@@ -228,7 +213,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
return new SearchPageMap(map);
|
||||
}
|
||||
|
||||
private void UpdateMovie(Movie movie, IndexWriter writer)
|
||||
private void UpdateMovie(Movie movie)
|
||||
{
|
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
@@ -248,6 +233,8 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
AddLanguages(doc, movie.MediaVersions);
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
@@ -277,7 +264,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -287,7 +274,21 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateShow(Show show, IndexWriter writer)
|
||||
private void AddLanguages(Document doc, List<MediaVersion> mediaVersions)
|
||||
{
|
||||
Option<MediaVersion> maybeVersion = mediaVersions.HeadOrNone();
|
||||
if (maybeVersion.IsSome)
|
||||
{
|
||||
MediaVersion version = maybeVersion.ValueUnsafe();
|
||||
foreach (string lang in version.Streams.Map(ms => ms.Language).Distinct()
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s)))
|
||||
{
|
||||
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
@@ -307,6 +308,12 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForShow(show);
|
||||
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
|
||||
{
|
||||
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
|
||||
}
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
@@ -336,7 +343,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
|
||||
_writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -346,7 +353,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMusicVideo(MusicVideo musicVideo, IndexWriter writer)
|
||||
private void UpdateMusicVideo(MusicVideo musicVideo)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
@@ -367,6 +374,8 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
AddLanguages(doc, musicVideo.MediaVersions);
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
@@ -396,7 +405,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
|
||||
_writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.HDHR.Queries;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -20,8 +22,10 @@ namespace ErsatzTV.Controllers
|
||||
new OkObjectResult(new DeviceXml(Request.Scheme, Request.Host.ToString()));
|
||||
|
||||
[HttpGet("discover.json")]
|
||||
public IActionResult Discover() =>
|
||||
new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), 2));
|
||||
[ResponseCache(NoStore = true)]
|
||||
public Task<IActionResult> Discover() =>
|
||||
_mediator.Send(new GetHDHRTunerCount()).Map<int, IActionResult>(
|
||||
tunerCount => new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), tunerCount)));
|
||||
|
||||
[HttpGet("lineup_status.json")]
|
||||
public IActionResult LineupStatus() =>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="FluentValidation" Version="9.5.3" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using ErsatzTV.Application.Images.Commands
|
||||
@using ErsatzTV.Application.MediaItems.Queries
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Channels.Queries
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -34,7 +36,13 @@
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField Class="mt-3" Label="Preferred Language Code" @bind-Value="_model.PreferredLanguageCode" For="@(() => _model.PreferredLanguageCode)"/>
|
||||
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_model.PreferredLanguageCode" For="@(() => _model.PreferredLanguageCode)">
|
||||
<MudSelectItem Value="@((string) null)">(none)</MudSelectItem>
|
||||
@foreach (CultureInfo culture in _availableCultures)
|
||||
{
|
||||
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||
<MudItem xs="6">
|
||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||
@@ -74,10 +82,12 @@
|
||||
private ValidationMessageStore _messageStore;
|
||||
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles;
|
||||
private List<CultureInfo> _availableCultures;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await LoadFFmpegProfilesAsync();
|
||||
_availableCultures = await Mediator.Send(new GetAllLanguageCodes());
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using ErsatzTV.Application.Channels.Queries
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using System.Globalization
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
@@ -99,7 +100,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels())
|
||||
.Map(list => list.OrderBy(c => decimal.Parse(c.Number)).ToList());
|
||||
private async Task LoadChannelsAsync()
|
||||
{
|
||||
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels());
|
||||
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number));
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
_channels = new List<ChannelViewModel>();
|
||||
foreach (ChannelViewModel channel in sorted)
|
||||
{
|
||||
Option<CultureInfo> maybeCultureInfo = allCultures.Find(
|
||||
ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
channel.PreferredLanguageCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
maybeCultureInfo.Match(
|
||||
cultureInfo => _channels.Add(channel with { PreferredLanguageCode = cultureInfo.EnglishName }),
|
||||
() => _channels.Add(channel));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -243,12 +243,15 @@
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
|
||||
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
|
||||
.ToList();
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
|
||||
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
@@ -2,51 +2,13 @@
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@using System.Globalization
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<FFmpeg> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">FFmpeg Settings</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudForm @bind-IsValid="@_success">
|
||||
<MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField T="string" Label="Preferred Language Code" @bind-Value="_ffmpegSettings.PreferredLanguageCode" Validation="@(new Func<string, string>(ValidateLanguageCode))" Required="true" RequiredError="Preferred Language Code is required!"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSwitch T="bool"
|
||||
Label="Save troubleshooting reports to disk"
|
||||
Color="Color.Primary"
|
||||
@bind-Checked="@_ffmpegSettings.SaveReports"/>
|
||||
</MudElement>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveSettings())">Save Settings</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
|
||||
<MudTable Hover="true" Items="_ffmpegProfiles" Class="mt-8">
|
||||
<MudTable Hover="true" Items="_ffmpegProfiles">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
|
||||
<MudToolBarSpacer></MudToolBarSpacer>
|
||||
@@ -110,45 +72,9 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
private bool _success;
|
||||
private FFmpegSettingsViewModel _ffmpegSettings;
|
||||
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
|
||||
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
|
||||
await LoadFFmpegProfilesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
|
||||
}
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
|
||||
private static string ValidateLanguageCode(string languageCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(languageCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Option<CultureInfo> culture = CultureInfo.GetCultures(CultureTypes.NeutralCultures)
|
||||
.FirstOrDefault(ci => string.Equals(ci.ThreeLetterISOLanguageName, languageCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return culture.IsNone ? "Preferred language code is invalid" : null;
|
||||
}
|
||||
protected override async Task OnParametersSetAsync() => await LoadFFmpegProfilesAsync();
|
||||
|
||||
private async Task LoadFFmpegProfilesAsync() =>
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
|
||||
|
||||
@@ -62,12 +62,12 @@
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Frame Rate" @bind-Value="_model.FrameRate" For="@(() => _model.FrameRate)" Adornment="Adornment.End" AdornmentText="fps"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Audio</MudText>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudElement>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
@page "/media/libraries"
|
||||
@using MediatR.Courier
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.Libraries.Queries
|
||||
@using ErsatzTV.Application.MediaSources.Commands
|
||||
@using ErsatzTV.Application.Plex.Commands
|
||||
@using ErsatzTV.Core.Metadata
|
||||
@using System.Threading
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker Locker
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel
|
||||
@inject ChannelWriter<IPlexBackgroundServiceRequest> PlexWorkerChannel
|
||||
@inject ICourier Courier
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_libraries" Dense="true">
|
||||
@@ -17,7 +22,7 @@
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
<col style="width: 180px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Library Kind</MudTh>
|
||||
@@ -33,12 +38,21 @@
|
||||
<div style="align-items: center; display: flex;">
|
||||
@if (Locker.IsLibraryLocked(context.Id))
|
||||
{
|
||||
<div style="width: 48px">
|
||||
@if (_progressByLibrary[context.Id] > 0)
|
||||
{
|
||||
<MudText Color="Color.Primary">
|
||||
@($"{_progressByLibrary[context.Id]} %")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<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
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
<MudTooltip Text="Scan Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@Locker.IsLibraryLocked(context.Id)"
|
||||
@@ -63,14 +77,21 @@
|
||||
|
||||
@code {
|
||||
private IList<LibraryViewModel> _libraries;
|
||||
private Dictionary<int, int> _progressByLibrary;
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Locker.OnLibraryChanged += LockChanged;
|
||||
Courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadLibraries();
|
||||
|
||||
private async Task LoadLibraries() =>
|
||||
private async Task LoadLibraries()
|
||||
{
|
||||
_libraries = await Mediator.Send(new GetAllLibraries());
|
||||
_progressByLibrary = _libraries.ToDictionary(vm => vm.Id, _ => 0);
|
||||
}
|
||||
|
||||
private async Task ScanLibrary(LibraryViewModel library)
|
||||
{
|
||||
@@ -79,10 +100,10 @@
|
||||
switch (library)
|
||||
{
|
||||
case LocalLibraryViewModel:
|
||||
await Channel.WriteAsync(new ForceScanLocalLibrary(library.Id));
|
||||
await WorkerChannel.WriteAsync(new ForceScanLocalLibrary(library.Id));
|
||||
break;
|
||||
case PlexLibraryViewModel:
|
||||
await Channel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id));
|
||||
await PlexWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id));
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -93,6 +114,26 @@
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
void IDisposable.Dispose() => Locker.OnLibraryChanged -= LockChanged;
|
||||
private async Task HandleScanProgress(LibraryScanProgress libraryScanProgress, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_progressByLibrary != null && _progressByLibrary.ContainsKey(libraryScanProgress.LibraryId))
|
||||
{
|
||||
_progressByLibrary[libraryScanProgress.LibraryId] = (int) (libraryScanProgress.Progress * 100);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Locker.OnLibraryChanged -= LockChanged;
|
||||
Courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
{
|
||||
if (Locker.LockLibrary(_library.Id))
|
||||
{
|
||||
await Channel.WriteAsync(new ForceRescanLocalLibrary(_library.Id));
|
||||
await Channel.WriteAsync(new ScanLocalLibraryIfNeeded(_library.Id));
|
||||
NavigationManager.NavigateTo("/media/libraries");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,16 +6,22 @@
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
|
||||
<HeaderContent>
|
||||
<MudTh>Timestamp</MudTh>
|
||||
<MudTh>Level</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Timestamp)">
|
||||
Timestamp
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Level)">
|
||||
Level
|
||||
</MudTableSortLabel>
|
||||
</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>
|
||||
<MudTd DataLabel="Message">@context.Message</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
|
||||
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
|
||||
@@ -56,15 +56,14 @@
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
<FragmentLetterAnchor TCard="MovieCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Link="@($"/media/movies/{context.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
|
||||
@@ -56,16 +56,15 @@
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MusicVideoCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
<FragmentLetterAnchor TCard="MusicVideoCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Link=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
@@ -132,7 +131,10 @@
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems() => _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_channels = await Mediator.Send(new GetAllChannels());
|
||||
_programSchedules = await Mediator.Send(new GetAllProgramSchedules());
|
||||
_channels = await Mediator.Send(new GetAllChannels())
|
||||
.Map(list => list.OrderBy(vm => decimal.Parse(vm.Number)).ToList());
|
||||
_programSchedules = await Mediator.Send(new GetAllProgramSchedules())
|
||||
.Map(list => list.OrderBy(vm => vm.Name).ToList());
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<PlexLibrariesEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
@inject ChannelWriter<IPlexBackgroundServiceRequest> Channel
|
||||
@inject IEntityLocker Locker
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
|
||||
@@ -128,10 +128,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void PlexChanged(object sender, EventArgs e)
|
||||
private async void PlexChanged(object sender, EventArgs e)
|
||||
{
|
||||
InvokeAsync(LoadMediaSources);
|
||||
InvokeAsync(StateHasChanged);
|
||||
await InvokeAsync(LoadMediaSources);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() => Locker.OnPlexChanged -= PlexChanged;
|
||||
|
||||
@@ -169,10 +169,13 @@
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems() => _movies.Cards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _movies.Cards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
@page "/settings"
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using ErsatzTV.Application.HDHR.Commands
|
||||
@using ErsatzTV.Application.HDHR.Queries
|
||||
@using ErsatzTV.Application.MediaItems.Queries
|
||||
@using System.Globalization
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<Settings> Logger
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row">
|
||||
<MudCard Class="mr-6" Style="max-width: 400px">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">FFmpeg Settings</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudForm @bind-IsValid="@_success">
|
||||
<MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_ffmpegSettings.PreferredLanguageCode" For="@(() => _ffmpegSettings.PreferredLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
|
||||
@foreach (CultureInfo culture in _availableCultures)
|
||||
{
|
||||
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSwitch T="bool"
|
||||
Label="Save troubleshooting reports to disk"
|
||||
Color="Color.Primary"
|
||||
@bind-Checked="@_ffmpegSettings.SaveReports"/>
|
||||
</MudElement>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveFFmpegSettings())">Save Settings</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
<MudCard Style="width: 350px">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">HDHomeRun Settings</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudForm @bind-IsValid="@_hdhrSuccess">
|
||||
<MudTextField T="int" Label="Tuner Count" @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_hdhrSuccess)" OnClick="@(_ => SaveHDHRSettings())">Save Settings</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private bool _success;
|
||||
private bool _hdhrSuccess;
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles;
|
||||
private FFmpegSettingsViewModel _ffmpegSettings;
|
||||
private List<CultureInfo> _availableCultures;
|
||||
private int _tunerCount;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await LoadFFmpegProfilesAsync();
|
||||
|
||||
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
|
||||
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
|
||||
_availableCultures = await Mediator.Send(new GetAllLanguageCodes());
|
||||
_tunerCount = await Mediator.Send(new GetHDHRTunerCount());
|
||||
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
|
||||
}
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
|
||||
private static string ValidateTunerCount(int tunerCount) => tunerCount <= 0 ? "Tuner count must be greater than zero" : null;
|
||||
|
||||
private async Task LoadFFmpegProfilesAsync() =>
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
|
||||
|
||||
private async Task SaveFFmpegSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
|
||||
}
|
||||
|
||||
private async Task SaveHDHRSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateHDHRTunerCount(_tunerCount));
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(_season.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
|
||||
src="@($"/artwork/posters/{_season.Poster}")" alt="show poster"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(_show.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
|
||||
src="@($"/artwork/posters/{_show.Poster}")" alt="show poster"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
|
||||
@@ -56,15 +56,14 @@
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
<FragmentLetterAnchor TCard="TelevisionShowCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Link="@($"/media/tv/shows/{context.TelevisionShowId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
|
||||
@@ -52,17 +52,24 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
try
|
||||
{
|
||||
Task requestTask = request switch
|
||||
Task requestTask;
|
||||
switch (request)
|
||||
{
|
||||
TryCompletePlexPinFlow pinRequest => CompletePinFlow(pinRequest, cancellationToken),
|
||||
SynchronizePlexMediaSources sourcesRequest => SynchronizeSources(
|
||||
sourcesRequest,
|
||||
cancellationToken),
|
||||
SynchronizePlexLibraries synchronizePlexLibrariesRequest => SynchronizeLibraries(
|
||||
synchronizePlexLibrariesRequest,
|
||||
cancellationToken),
|
||||
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
|
||||
};
|
||||
case TryCompletePlexPinFlow pinRequest:
|
||||
requestTask = CompletePinFlow(pinRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexMediaSources sourcesRequest:
|
||||
requestTask = SynchronizeSources(sourcesRequest, cancellationToken);
|
||||
break;
|
||||
case SynchronizePlexLibraries synchronizePlexLibrariesRequest:
|
||||
requestTask = SynchronizeLibraries(synchronizePlexLibrariesRequest, cancellationToken);
|
||||
break;
|
||||
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
|
||||
requestTask = SynchronizePlexLibrary(synchronizePlexLibraryById, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
|
||||
}
|
||||
|
||||
await requestTask;
|
||||
}
|
||||
@@ -109,17 +116,8 @@ namespace ErsatzTV.Services
|
||||
|
||||
Either<BaseError, bool> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
success =>
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("Successfully authenticated with plex");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Plex authentication timeout");
|
||||
}
|
||||
},
|
||||
success => _logger.LogInformation(
|
||||
success ? "Successfully authenticated with plex" : "Plex authentication timeout"),
|
||||
error => _logger.LogWarning("Unable to poll plex token: {Error}", error.Value));
|
||||
}
|
||||
|
||||
@@ -138,5 +136,21 @@ namespace ErsatzTV.Services
|
||||
request.PlexMediaSourceId,
|
||||
error.Value));
|
||||
}
|
||||
|
||||
private async Task SynchronizePlexLibrary(
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing plex library {Name}", name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,22 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
public class SchedulerService : BackgroundService
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILogger<SchedulerService> _logger;
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _plexWorkerChannel;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public SchedulerService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> plexWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SchedulerService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_channel = channel;
|
||||
_workerChannel = workerChannel;
|
||||
_plexWorkerChannel = plexWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -74,7 +77,7 @@ namespace ErsatzTV.Services
|
||||
List<int> playoutIds = await dbContext.Playouts.Map(p => p.Id).ToListAsync(cancellationToken);
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId), cancellationToken);
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +95,7 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
if (_entityLocker.LockLibrary(libraryId))
|
||||
{
|
||||
await _channel.WriteAsync(
|
||||
await _workerChannel.WriteAsync(
|
||||
new ScanLocalLibraryIfNeeded(libraryId),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -112,7 +115,7 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
if (_entityLocker.LockLibrary(library.Id))
|
||||
{
|
||||
await _channel.WriteAsync(
|
||||
await _plexWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -120,6 +123,6 @@ namespace ErsatzTV.Services
|
||||
}
|
||||
|
||||
private ValueTask RebuildSearchIndex(CancellationToken cancellationToken) =>
|
||||
_channel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
|
||||
_workerChannel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Application.Plex.Commands;
|
||||
using ErsatzTV.Application.Search.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
@@ -70,19 +69,6 @@ namespace ErsatzTV.Services
|
||||
scanLocalLibrary.LibraryId,
|
||||
error.Value));
|
||||
break;
|
||||
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
|
||||
Either<BaseError, string> result = await mediator.Send(
|
||||
synchronizePlexLibraryById,
|
||||
cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug(
|
||||
"Done synchronizing plex library {Name}",
|
||||
name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
synchronizePlexLibraryById.PlexLibraryId,
|
||||
error.Value));
|
||||
break;
|
||||
case RebuildSearchIndex rebuildSearchIndex:
|
||||
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@typeparam TCard
|
||||
|
||||
@{ var letters = new System.Collections.Generic.HashSet<char>(); }
|
||||
@foreach (TCard card in Cards.Filter(c => !string.IsNullOrWhiteSpace(c.Title)).OrderBy(c => c.SortTitle))
|
||||
{
|
||||
@if (!letters.Contains(card.SortTitle.Head()))
|
||||
{
|
||||
Option<char> maybeLetter = card.SortTitle.ToLowerInvariant().HeadOrNone();
|
||||
if (maybeLetter.IsSome)
|
||||
{
|
||||
char letter = maybeLetter.ValueUnsafe();
|
||||
if (letter >= '0' && letter <= '9')
|
||||
{
|
||||
letter = '#';
|
||||
}
|
||||
letters.Add(letter);
|
||||
<div id="@($"letter-{letter}")" style="scroll-margin-top: 128px">
|
||||
@ChildContent(card)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ErsatzTV.Shared
|
||||
{
|
||||
public partial class FragmentLetterAnchor<TCard> where TCard : MediaCardViewModel
|
||||
{
|
||||
[Parameter]
|
||||
public RenderFragment<TCard> ChildContent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public List<TCard> Cards { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", Query);
|
||||
}
|
||||
return uri;
|
||||
return uri + $"#letter-{letter}";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
<MudLink Color="Color.Info" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
|
||||
<MudLink Color="Color.Info" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
|
||||
<MudLink Color="Color.Info" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
|
||||
<MudTooltip Text="Documentation">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Help" Color="Color.Primary" Link="https://ersatztv.org" Target="_blank"/>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Discord">
|
||||
<MudIconButton Icon="fab fa-discord" Color="Color.Primary" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
|
||||
</MudTooltip>
|
||||
@@ -39,7 +42,7 @@
|
||||
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/channels">Channels</MudNavLink>
|
||||
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
|
||||
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
|
||||
<MudNavGroup Title="Media Sources" Expanded="true">
|
||||
<MudNavLink Href="/media/plex">Plex</MudNavLink>
|
||||
</MudNavGroup>
|
||||
@@ -52,6 +55,7 @@
|
||||
</MudNavGroup>
|
||||
<MudNavLink Href="/schedules">Schedules</MudNavLink>
|
||||
<MudNavLink Href="/playouts">Playouts</MudNavLink>
|
||||
<MudNavLink Href="/settings">Settings</MudNavLink>
|
||||
<MudNavLink Href="/system/logs">Logs</MudNavLink>
|
||||
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
|
||||
<MudContainer Style="text-align: right" Class="mr-6">
|
||||
|
||||
@@ -33,6 +33,7 @@ using ErsatzTV.Services;
|
||||
using ErsatzTV.Services.RunOnce;
|
||||
using FluentValidation.AspNetCore;
|
||||
using MediatR;
|
||||
using MediatR.Courier.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -88,6 +89,7 @@ namespace ErsatzTV
|
||||
services.AddServerSideBlazor();
|
||||
|
||||
services.AddMudServices();
|
||||
services.AddCourier(Assembly.GetAssembly(typeof(LibraryScanProgress)));
|
||||
|
||||
Log.Logger.Information(
|
||||
"ErsatzTV version {Version}",
|
||||
@@ -183,6 +185,7 @@ namespace ErsatzTV
|
||||
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
|
||||
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
|
||||
services.AddSingleton<IEntityLocker, EntityLocker>();
|
||||
services.AddSingleton<ISearchIndex, SearchIndex>();
|
||||
AddChannel<IBackgroundServiceRequest>(services);
|
||||
AddChannel<IPlexBackgroundServiceRequest>(services);
|
||||
|
||||
@@ -215,7 +218,6 @@ namespace ErsatzTV
|
||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
services.AddScoped<ISearchIndex, SearchIndex>();
|
||||
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
|
||||
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
|
||||
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
**ErsatzTV** is pre-alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
|
||||
|
||||
Documentation is available at [ersatztv.org](https://ersatztv.org/).
|
||||
|
||||
Want to join the community or have a question? Join us on [Discord](https://discord.gg/hHaJm3yGy6).
|
||||
|
||||
## Current Features
|
||||
@@ -12,69 +14,24 @@ Want to join the community or have a question? Join us on [Discord](https://disc
|
||||
- Collection-based scheduling, with collections containing television shows, seasons, episodes and movies
|
||||
- Powerful scheduling options such as chronological collection playback throughout the day or over multiple days
|
||||
- [Hardware-accelerated transcoding](#Hardware-Transcoding) options (QSV, NVENC, VAAPI)
|
||||
- [Plex](https://www.plex.tv/) media and metadata
|
||||
- Music video libraries
|
||||
|
||||
## In Development
|
||||
|
||||
- [Plex](https://www.plex.tv/) media, metadata and collections
|
||||
|
||||
## Planned Features
|
||||
|
||||
- [Jellyfin](https://jellyfin.org/) media, metadata and collections
|
||||
- [Jellyfin](https://jellyfin.org/) media and metadata
|
||||
- Run as a Windows service
|
||||
- Spots to fill unscheduled gaps
|
||||
|
||||
## Preview
|
||||
## Screenshots
|
||||
|
||||
### Television Show
|
||||
|
||||

|
||||

|
||||
|
||||
### Media Collection
|
||||
|
||||

|
||||
|
||||
### Plex Live TV
|
||||
|
||||

|
||||
Sintel is © copyright Blender Foundation | durian.blender.org
|
||||
|
||||
## Running ErsatzTV
|
||||
|
||||
The easiest way to run ErsatzTV is with Docker:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-e TZ=America/Chicago \
|
||||
-p 8409:8409 \
|
||||
-v /path/to/appdata/config:/root/.local/share/ersatztv \
|
||||
-v /path/to/shared/media:/path/to/shared/media:ro \
|
||||
--restart unless-stopped \
|
||||
jasongdove/ersatztv
|
||||
```
|
||||
|
||||
After running ErsatzTV for the first time, configure it by visiting the web UI at http://[address]:8409.
|
||||
|
||||
### Software Transcoding
|
||||
|
||||
The following docker tags are available with software transcoding:
|
||||
|
||||
* `develop` - merges to `main` branch
|
||||
* `latest` - latest release
|
||||
|
||||
### Hardware Transcoding
|
||||
|
||||
The following docker tags are available with hardware-accelerated transcoding:
|
||||
|
||||
* `develop-nvidia` - merges to `main` branch
|
||||
* `develop-vaapi` - merges to `main` branch
|
||||
* `latest-nvidia` - latest release
|
||||
* `latest-vaapi` - latest release
|
||||
|
||||
QSV transcoding has not been tested in docker.
|
||||
|
||||
## Development
|
||||
|
||||
See [development documentation](docs/development.md).
|
||||

|
||||
|
||||
## License
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 33 KiB |