Browse Source

allow other videos and images to use the same folders (#2087)

pull/2088/head
Jason Dove 11 months ago committed by GitHub
parent
commit
c6ee41484e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 76
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
  3. 6
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs
  4. 51
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs
  5. 37
      ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs
  6. 1
      ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs
  7. 2
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  8. 3
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  9. 8
      ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs
  10. 2
      ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs
  11. 2
      ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs
  12. 2
      ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs
  13. 2
      ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs
  14. 4
      ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs
  15. 5
      ErsatzTV/Pages/Channels.razor
  16. 91
      ErsatzTV/Pages/LocalLibraryPathEditor.razor

3
CHANGELOG.md

@ -37,6 +37,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -37,6 +37,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Shows will have new `network` search field
- Episodes will have new `show_network` search field
### Changed
- Allow `Other Video` libraries and `Image` libraries to use the same folders
### Fixed
- Fix QSV acceleration in docker with older Intel devices
- Fix HDR transcoding with NVIDIA accel for:

76
ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs

@ -1,76 +0,0 @@ @@ -1,76 +0,0 @@
using System.Threading.Channels;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
public CreateLocalLibraryHandler(
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_scannerWorkerChannel = scannerWorkerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
TvContext dbContext,
LocalLibrary localLibrary)
{
await dbContext.LocalLibraries.AddAsync(localLibrary);
await dbContext.SaveChangesAsync();
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
return ProjectToViewModel(localLibrary);
}
private static Task<Validation<BaseError, LocalLibrary>> Validate(
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
TvContext dbContext,
CreateLocalLibrary request) =>
dbContext.LocalMediaSources
.OrderBy(lms => lms.Id)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(
lms => new LocalLibrary
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
MediaKind = request.MediaKind,
MediaSourceId = lms.Id
})
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
}

6
ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs

@ -1,6 +0,0 @@ @@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Libraries;
public record CreateLocalLibraryPath(int LibraryId, string Path)
: IRequest<Either<BaseError, LocalLibraryPathViewModel>>;

51
ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs

@ -1,51 +0,0 @@ @@ -1,51 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries;
public class CreateLocalLibraryPathHandler : IRequestHandler<CreateLocalLibraryPath,
Either<BaseError, LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync());
private Task<LocalLibraryPathViewModel> PersistLocalLibraryPath(LibraryPath p) =>
_libraryRepository.Add(p).Map(ProjectToViewModel);
private Task<Validation<BaseError, LibraryPath>> Validate(CreateLocalLibraryPath request) =>
ValidateFolder(request)
.MapT(
folder =>
new LibraryPath
{
LibraryId = request.LibraryId,
Path = folder
});
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalLibraryPath request)
{
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Where(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}

37
ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs

@ -19,23 +19,44 @@ public abstract class LocalLibraryHandlerBase @@ -19,23 +19,44 @@ public abstract class LocalLibraryHandlerBase
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> allPaths = await dbContext.LocalLibraries
List<LocalPath> allPaths = await dbContext.LocalLibraries
.Include(ll => ll.Paths)
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
.ToListAsync()
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
.Map(list => list.SelectMany(ll => ll.Paths.Map(lp => new LocalPath(ll.MediaKind, lp.Path))).ToList());
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
var localPaths = localLibrary.Paths.Map(lp => new LocalPath(localLibrary.MediaKind, lp.Path)).ToList();
return Optional(localPaths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder))))
.Where(length => length == 0)
.Map(_ => localLibrary)
.ToValidation<BaseError>("Path must not belong to another library path");
}
private static bool AreSubPaths(string path1, string path2)
private static bool AreSubPaths(LocalPath path1, LocalPath path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
string one = path1.Path + Path.DirectorySeparatorChar;
string two = path2.Path + Path.DirectorySeparatorChar;
bool isConflict = one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
// Images and OtherVideos do not conflict
if (isConflict)
{
bool imagesAndOtherVideos = (path1.MediaKind is LibraryMediaKind.Images &&
path2.MediaKind is LibraryMediaKind.OtherVideos)
|| (path2.MediaKind is LibraryMediaKind.Images &&
path1.MediaKind is LibraryMediaKind.OtherVideos);
if (imagesAndOtherVideos)
{
isConflict = false;
}
}
return isConflict;
}
protected record LocalPath(LibraryMediaKind MediaKind, string Path);
}

1
ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs

@ -119,6 +119,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, @@ -119,6 +119,7 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
{
Name = request.Name,
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
MediaKind = existing.MediaKind,
MediaSourceId = existing.Id
};

2
ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs

@ -14,7 +14,7 @@ public interface ILibraryRepository @@ -14,7 +14,7 @@ public interface ILibraryRepository
Task<int> CountMediaItemsByPath(int libraryPathId);
Task SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task CleanEtagsForLibraryPath(LibraryPath libraryPath);
Task<Option<int>> GetParentFolderId(string folder);
Task<Option<int>> GetParentFolderId(LibraryPath libraryPath, string folder);
Task<LibraryFolder> GetOrAddFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder);
Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId);
Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath);

3
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -141,7 +141,7 @@ public class LibraryRepository : ILibraryRepository @@ -141,7 +141,7 @@ public class LibraryRepository : ILibraryRepository
}
}
public async Task<Option<int>> GetParentFolderId(string folder)
public async Task<Option<int>> GetParentFolderId(LibraryPath libraryPath, string folder)
{
DirectoryInfo parent = new DirectoryInfo(folder).Parent;
if (parent is null)
@ -153,6 +153,7 @@ public class LibraryRepository : ILibraryRepository @@ -153,6 +153,7 @@ public class LibraryRepository : ILibraryRepository
return await dbContext.LibraryFolders
.AsNoTracking()
.Filter(lf => lf.LibraryPathId == libraryPath.Id)
.SelectOneAsync(lf => lf.Path, lf => lf.Path == parent.FullName)
.MapT(lf => lf.Id);
}

8
ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs

@ -114,12 +114,12 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner @@ -114,12 +114,12 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
libraryPath.LibraryId,
null,
progressMin + percentCompletion * progressSpread,
Array.Empty<int>(),
Array.Empty<int>()),
[],
[]),
cancellationToken);
string imageFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(imageFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, imageFolder);
foldersCompleted++;
@ -214,7 +214,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner @@ -214,7 +214,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
null,
null,
[result.Item.Id],
Array.Empty<int>()),
[]),
cancellationToken);
}
}

2
ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs

@ -117,7 +117,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -117,7 +117,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
cancellationToken);
string movieFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(movieFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, movieFolder);
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList();

2
ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs

@ -332,7 +332,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -332,7 +332,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
string musicVideoFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(musicVideoFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, musicVideoFolder);
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);

2
ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs

@ -128,7 +128,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -128,7 +128,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
cancellationToken);
string otherVideoFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(otherVideoFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, otherVideoFolder);
foldersCompleted++;

2
ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs

@ -117,7 +117,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -117,7 +117,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
cancellationToken);
string songFolder = folderQueue.Dequeue();
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(songFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, songFolder);
foldersCompleted++;

4
ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs

@ -113,7 +113,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -113,7 +113,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
Array.Empty<int>()),
cancellationToken);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(showFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, showFolder);
// this folder is unused by the show, but will be used as parents of season folders
LibraryFolder _ = await _libraryRepository.GetOrAddFolder(
@ -244,7 +244,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -244,7 +244,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
return new ScanCanceled();
}
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(seasonFolder);
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, seasonFolder);
string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem);
LibraryFolder knownFolder = await _libraryRepository.GetOrAddFolder(

5
ErsatzTV/Pages/Channels.razor

@ -144,6 +144,11 @@ @@ -144,6 +144,11 @@
private bool CanPreviewChannel(ChannelViewModel channel)
{
if (channel.ActiveMode is ChannelActiveMode.Inactive)
{
return false;
}
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream)
{
return false;

91
ErsatzTV/Pages/LocalLibraryPathEditor.razor

@ -1,91 +0,0 @@ @@ -1,91 +0,0 @@
@page "/media/libraries/local/{Id:int}/add"
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.MediaSources
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<LocalLibraryPathEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">@_library.Name - Add Local Library Path</MudText>
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidationValidator/>
<MudCard>
<MudCardContent>
<MudTextField T="string" Label="Media Kind" Disabled="true" Value="@(Enum.GetName(typeof(LibraryMediaKind), _library.MediaKind))"/>
@* TODO: replace this with a folder picker *@
<MudTextField Label="Path" @bind-Value="_model.Path" For="@(() => _model.Path)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
Add Local Library Path
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
</MudContainer>
@code {
private readonly CancellationTokenSource _cts = new();
[Parameter]
public int Id { get; set; }
private readonly LocalLibraryPathEditViewModel _model = new();
private EditContext _editContext;
private ValidationMessageStore _messageStore;
private LocalLibraryViewModel _library;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override async Task OnParametersSetAsync()
{
Option<LocalLibraryViewModel> maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id), _cts.Token);
maybeLibrary.Match(
library => _library = library,
() => NavigationManager.NavigateTo("404"));
}
protected override void OnInitialized()
{
_editContext = new EditContext(_model);
_messageStore = new ValidationMessageStore(_editContext);
}
private async Task HandleSubmitAsync()
{
_messageStore.Clear();
if (_editContext.Validate())
{
var command = new CreateLocalLibraryPath(_library.Id, _model.Path);
Either<BaseError, LocalLibraryPathViewModel> result = await Mediator.Send(command, _cts.Token);
await result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving local library path: {Error}", error.Value);
return Task.CompletedTask;
},
Right: async _ =>
{
if (Locker.LockLibrary(_library.Id))
{
await ScannerWorkerChannel.WriteAsync(new ScanLocalLibraryIfNeeded(_library.Id), _cts.Token);
NavigationManager.NavigateTo("media/libraries");
}
});
}
}
}
Loading…
Cancel
Save