Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39654ca40 | ||
|
|
f17151bd20 | ||
|
|
6aeaf65a13 | ||
|
|
9fbe950e6e | ||
|
|
c9baff2cd5 |
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 3 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
+10
-1
@@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.52-alpha] - 2021-07-22
|
||||
### Added
|
||||
- Add multiple local libraries to better organize your media
|
||||
- Add `Move Library Path` function to support reorganizing existing local libraries
|
||||
|
||||
### Fixed
|
||||
- Fix bug preventing playouts from rebuilding after an empty collection is encountered within a multi-collection
|
||||
|
||||
## [0.0.51-alpha] - 2021-07-18
|
||||
### Added
|
||||
- Add `Multi Collection` to support shuffling multiple collections within a single schedule item
|
||||
@@ -520,7 +528,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...HEAD
|
||||
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
|
||||
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha
|
||||
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
|
||||
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record CreateLocalLibrary(string Name, LibraryMediaKind MediaKind, List<string> Paths)
|
||||
: ILocalLibraryRequest, IRequest<Either<BaseError, LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
|
||||
CreateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
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 _workerChannel.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."));
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
|
||||
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) =>
|
||||
_libraryRepository = mediaSourceRepository;
|
||||
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
|
||||
_libraryRepository = libraryRepository;
|
||||
|
||||
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
|
||||
CreateLocalLibraryPath request,
|
||||
@@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
|
||||
.Map(list => list.Map(c => c.Path).ToList());
|
||||
|
||||
|
||||
return Optional(request.Path)
|
||||
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
|
||||
.ToValidation<BaseError>("Path must not belong to another library path");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record DeleteLocalLibrary(int LocalLibraryId) : IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<DeleteLocalLibrary, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IDbConnection dbConnection,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
|
||||
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = localLibrary.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
dbContext.LocalLibraries.Remove(localLibrary);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteLocalLibrary request) =>
|
||||
dbContext.LocalLibraries
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
|
||||
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class
|
||||
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
MediaSourceMustExist(request)
|
||||
.MapT(DoDeletion)
|
||||
.Bind(t => t.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
|
||||
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
||||
.HeadOrNone()
|
||||
.ToValidation<BaseError>(
|
||||
$"Local library path {request.LocalLibraryPathId} does not exist.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public interface ILocalLibraryRequest
|
||||
{
|
||||
public string Name { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public abstract class LocalLibraryHandlerBase
|
||||
{
|
||||
protected static Task<Validation<BaseError, LocalLibrary>> NameMustBeValid(
|
||||
ILocalLibraryRequest request,
|
||||
LocalLibrary localLibrary) =>
|
||||
request.NotEmpty(c => c.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
||||
.Map(_ => localLibrary).AsTask();
|
||||
|
||||
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary,
|
||||
int? existingLibraryId = null)
|
||||
{
|
||||
List<string> 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());
|
||||
|
||||
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
|
||||
.Filter(length => length == 0)
|
||||
.Map(_ => localLibrary)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record MoveLocalLibraryPath(int LibraryPathId, int TargetLibraryId) : IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IDbConnection dbConnection,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
LibraryPath path = parameters.LibraryPath;
|
||||
LocalLibrary newLibrary = parameters.Library;
|
||||
|
||||
path.LibraryId = newLibrary.Id;
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = path.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
dbContext.LibraryPaths
|
||||
.Include(lp => lp.Library)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
|
||||
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
|
||||
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
|
||||
|
||||
private async Task<string> GetPath(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Movie => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.MovieId = @Id", new { mediaItem.Id }),
|
||||
Episode => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.EpisodeId = @Id", new { mediaItem.Id }),
|
||||
MusicVideo => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.MusicVideoId = @Id", new { mediaItem.Id }),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record UpdateLocalLibraryPath(int Id, string Path);
|
||||
|
||||
public record UpdateLocalLibrary(int Id, string Name, List<UpdateLocalLibraryPath> Paths) : ILocalLibraryRequest,
|
||||
IRequest<Either<BaseError, LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<UpdateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> UpdateLocalLibrary(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
(LocalLibrary existing, LocalLibrary incoming) = parameters;
|
||||
existing.Name = incoming.Name;
|
||||
|
||||
// toAdd
|
||||
var toAdd = incoming.Paths
|
||||
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
|
||||
.ToList();
|
||||
var toRemove = existing.Paths
|
||||
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
|
||||
.ToList();
|
||||
|
||||
existing.Paths.RemoveAll(toRemove.Contains);
|
||||
existing.Paths.AddRange(toAdd);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
LocalLibraryMustExist(dbContext, request)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(
|
||||
parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
|
||||
.MapT(
|
||||
existing =>
|
||||
{
|
||||
var incoming = new LocalLibrary
|
||||
{
|
||||
Name = request.Name,
|
||||
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
|
||||
MediaSourceId = existing.Id
|
||||
};
|
||||
|
||||
return new Parameters(existing, incoming);
|
||||
})
|
||||
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist."));
|
||||
|
||||
private record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return Path.GetFullPath(new Uri(path).LocalPath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public class CountMediaItemsByLibraryHandler : IRequestHandler<CountMediaItemsByLibrary, int>
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
|
||||
public CountMediaItemsByLibraryHandler(IDbConnection dbConnection)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public Task<int> Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) =>
|
||||
_dbConnection.QuerySingleAsync<int>(
|
||||
@"SELECT COUNT(*) FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { request.LibraryId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public class GetAllLocalLibrariesHandler : IRequestHandler<GetAllLocalLibraries, List<LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
|
||||
public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
|
||||
|
||||
public Task<List<LocalLibraryViewModel>> Handle(
|
||||
GetAllLocalLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_libraryRepository.GetAll()
|
||||
.Map(
|
||||
list => list
|
||||
.OfType<LocalLibrary>()
|
||||
.OrderBy(l => l.MediaKind)
|
||||
.Map(ProjectToViewModel)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
AddArtistToCollection request) =>
|
||||
dbContext.Artists
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Music video does not exist"));
|
||||
.Map(o => o.ToValidation<BaseError>("Artist does not exist"));
|
||||
|
||||
private record Parameters(Collection Collection, Artist Artist);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
@@ -8,7 +9,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
var result = new List<GroupedMediaItem>();
|
||||
|
||||
foreach (CollectionWithItems collection in collections)
|
||||
foreach (CollectionWithItems collection in collections.Where(collection => collection.MediaItems.Any()))
|
||||
{
|
||||
if (collection.ScheduleAsGroup)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/media/emby/edit"
|
||||
@page "/media/sources/emby/edit"
|
||||
@using Unit = LanguageExt.Unit
|
||||
@using ErsatzTV.Core.Emby
|
||||
@using ErsatzTV.Application.Emby.Queries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/media/emby"
|
||||
@page "/media/sources/emby"
|
||||
@using ErsatzTV.Core.Interfaces.Emby
|
||||
@using ErsatzTV.Application.Emby.Commands
|
||||
@using ErsatzTV.Application.Emby.Queries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/media/jellyfin/edit"
|
||||
@page "/media/sources/jellyfin/edit"
|
||||
@using Unit = LanguageExt.Unit
|
||||
@using ErsatzTV.Core.Jellyfin
|
||||
@using ErsatzTV.Application.Jellyfin.Queries
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/media/jellyfin"
|
||||
@page "/media/sources/jellyfin"
|
||||
@using ErsatzTV.Core.Interfaces.Jellyfin
|
||||
@using ErsatzTV.Application.Jellyfin.Commands
|
||||
@using ErsatzTV.Application.Jellyfin.Queries
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 240px;"/>
|
||||
<col style="width: 180px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Library Kind</MudTh>
|
||||
@@ -71,15 +71,6 @@
|
||||
Link="@($"/search?query=library_id%3a{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
@if (context is LocalLibraryViewModel)
|
||||
{
|
||||
<MudTooltip Text="Edit Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@_locker.IsLibraryLocked(context.Id)"
|
||||
Link="@($"/media/libraries/local/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
@page "/media/sources/local"
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.Libraries.Commands
|
||||
@using ErsatzTV.Application.Libraries.Queries
|
||||
@implements IDisposable
|
||||
@inject IDialogService _dialog
|
||||
@inject IMediator _mediator
|
||||
@inject IEntityLocker _locker
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> _workerChannel
|
||||
@inject ChannelWriter<IPlexBackgroundServiceRequest> _plexWorkerChannel
|
||||
@inject ChannelWriter<IJellyfinBackgroundServiceRequest> _jellyfinWorkerChannel
|
||||
@inject ChannelWriter<IEmbyBackgroundServiceRequest> _embyWorkerChannel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_libraries" Dense="true">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Local Libraries</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Media Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Media Kind">@context.MediaKind</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@_locker.IsLibraryLocked(context.Id)"
|
||||
Link="@($"/media/sources/local/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Disabled="@_locker.IsLibraryLocked(context.Id)"
|
||||
OnClick="@(() => DeleteLibrary(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/sources/local/add" Class="mt-4">
|
||||
Add Local Library
|
||||
</MudButton>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private IList<LocalLibraryViewModel> _libraries;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_locker.OnLibraryChanged += LockChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadLibraries();
|
||||
|
||||
private async Task LoadLibraries()
|
||||
{
|
||||
_libraries = await _mediator.Send(new GetAllLocalLibraries());
|
||||
}
|
||||
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task DeleteLibrary(LocalLibraryViewModel library)
|
||||
{
|
||||
int count = await _mediator.Send(new CountMediaItemsByLibrary(library.Id));
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "EntityType", "library" },
|
||||
{ "EntityName", library.Name },
|
||||
{ "DetailText", $"This library contains {count} media items." },
|
||||
{ "DetailHighlight", count.ToString() }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Library", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled)
|
||||
{
|
||||
await _mediator.Send(new DeleteLocalLibrary(library.Id));
|
||||
await LoadLibraries();
|
||||
}
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_locker.OnLibraryChanged -= LockChanged;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +1,53 @@
|
||||
@page "/media/libraries/local/{Id:int}"
|
||||
@page "/media/sources/local/{Id:int}/edit"
|
||||
@page "/media/sources/local/add"
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.Libraries.Commands
|
||||
@using ErsatzTV.Application.Libraries.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@implements IDisposable
|
||||
@inject IDialogService _dialog
|
||||
@inject IEntityLocker _locker
|
||||
@inject IMediator _mediator
|
||||
@inject ILogger<LocalLibraryEditor> _logger
|
||||
@inject ISnackbar _snackbar
|
||||
@inject NavigationManager _navigationManager
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_libraryPaths" Dense="true">
|
||||
<div style="max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Local Library" : "Add Local Library")</MudText>
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="@SaveChangesAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudSelect Disabled="IsEdit" Label="MediaKind" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
|
||||
@foreach (LibraryMediaKind mediaKind in Enum.GetValues<LibraryMediaKind>())
|
||||
{
|
||||
<MudSelectItem Value="@mediaKind">@mediaKind</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField Class="mt-3" Label="Path" @bind-Value="_newPath.Path" For="@(() => _newPath.Path)"/>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddLibraryPath())" Class="ml-2">
|
||||
Add Path
|
||||
</MudButton>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="mr-2 ml-auto">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
<MudTable Hover="true" Items="_model.Paths" Dense="true" Class="mt-6">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Library Paths</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
<col style="width: 60px;"/>
|
||||
<col style="width: 120px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Path</MudTh>
|
||||
@@ -22,6 +57,12 @@
|
||||
<MudTd DataLabel="Path">@context.Path</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Move Library Path">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DriveFileMove"
|
||||
Disabled="@(_model.Id == 0 || context.Id == 0 || _locker.IsLibraryLocked(_model.Id))"
|
||||
OnClick="@(() => MoveLibraryPath(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Library Path">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(() => DeleteLibraryPath(context))">
|
||||
@@ -31,9 +72,6 @@
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="@($"/media/libraries/local/{Id}/add")" Class="mt-4">
|
||||
Add Library Path
|
||||
</MudButton>
|
||||
</MudContainer>
|
||||
|
||||
|
||||
@@ -42,17 +80,94 @@
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private IList<LocalLibraryPathViewModel> _libraryPaths;
|
||||
private readonly LocalLibraryEditViewModel _model = new();
|
||||
private LocalLibraryPathEditViewModel _newPath = new();
|
||||
private EditContext _editContext;
|
||||
private ValidationMessageStore _messageStore;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadLibraryPaths();
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
private async Task LoadLibraryPaths() =>
|
||||
_libraryPaths = await _mediator.Send(new GetLocalLibraryPaths(Id));
|
||||
|
||||
private async Task DeleteLibraryPath(LocalLibraryPathViewModel libraryPath)
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
int count = await _mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id));
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<LocalLibraryViewModel> maybeLibrary = await _mediator.Send(new GetLocalLibraryById(Id));
|
||||
await maybeLibrary.Match(
|
||||
async library =>
|
||||
{
|
||||
_model.Id = library.Id;
|
||||
_model.Name = library.Name;
|
||||
_model.MediaKind = library.MediaKind;
|
||||
|
||||
await LoadLibraryPaths();
|
||||
},
|
||||
() =>
|
||||
{
|
||||
_navigationManager.NavigateTo("404");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Name = "New Local Library";
|
||||
_model.MediaKind = LibraryMediaKind.Movies;
|
||||
_model.Paths = new List<LocalLibraryPathEditViewModel>();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_locker.OnLibraryChanged += LockChanged;
|
||||
|
||||
_editContext = new EditContext(_model);
|
||||
_messageStore = new ValidationMessageStore(_editContext);
|
||||
}
|
||||
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task LoadLibraryPaths()
|
||||
{
|
||||
_model.HasChanges = false;
|
||||
_model.Paths = await _mediator.Send(new GetLocalLibraryPaths(Id))
|
||||
.Map(list => list.Map(vm => new LocalLibraryPathEditViewModel
|
||||
{
|
||||
Id = vm.Id,
|
||||
Path = vm.Path
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
private async Task MoveLibraryPath(LocalLibraryPathEditViewModel libraryPath)
|
||||
{
|
||||
var parameters = new DialogParameters { { "MediaKind", _model.MediaKind }, { "SourceLibraryId", Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = _dialog.Show<MoveLocalLibraryPathDialog>("Move Local Library Path", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled && result.Data is LocalLibraryViewModel library)
|
||||
{
|
||||
var request = new MoveLocalLibraryPath(libraryPath.Id, library.Id);
|
||||
Either<BaseError, Unit> moveResult = await _mediator.Send(request);
|
||||
moveResult.Match(
|
||||
_ => _navigationManager.NavigateTo($"/media/sources/local/{library.Id}/edit"),
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add(error.Value, Severity.Error);
|
||||
_logger.LogError("Unexpected error moving local library path: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteLibraryPath(LocalLibraryPathEditViewModel libraryPath)
|
||||
{
|
||||
if (libraryPath.Id == 0)
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Remove(libraryPath);
|
||||
}
|
||||
|
||||
int count = await _mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id));
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "EntityType", "library path" },
|
||||
@@ -61,14 +176,65 @@
|
||||
{ "DetailHighlight", count.ToString() }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
|
||||
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Library Path", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (!result.Cancelled)
|
||||
{
|
||||
await _mediator.Send(new DeleteLocalLibraryPath(libraryPath.Id));
|
||||
await LoadLibraryPaths();
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Remove(libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLibraryPath()
|
||||
{
|
||||
if (_model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path)))
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Add(new LocalLibraryPathEditViewModel
|
||||
{
|
||||
Path = _newPath.Path
|
||||
});
|
||||
}
|
||||
|
||||
_newPath.Path = null;
|
||||
}
|
||||
|
||||
private async Task SaveChangesAsync()
|
||||
{
|
||||
_messageStore.Clear();
|
||||
if (_editContext.Validate())
|
||||
{
|
||||
Either<BaseError, LocalLibraryViewModel> result = IsEdit
|
||||
? await _mediator.Send(new UpdateLocalLibrary(
|
||||
_model.Id,
|
||||
_model.Name,
|
||||
_model.Paths.Map(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList()))
|
||||
: await _mediator.Send(new CreateLocalLibrary(
|
||||
_model.Name,
|
||||
_model.MediaKind,
|
||||
_model.Paths.Map(p => p.Path).ToList()));
|
||||
|
||||
result.Match(
|
||||
_ => _navigationManager.NavigateTo("/media/sources/local"),
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add(error.Value, Severity.Error);
|
||||
_logger.LogError("Unexpected error saving local library: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private string NormalizePath(string path)
|
||||
{
|
||||
return Path.GetFullPath(new Uri(path).LocalPath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_locker.OnLibraryChanged -= LockChanged;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/media/plex"
|
||||
@page "/media/sources/plex"
|
||||
@using ErsatzTV.Core.Interfaces.Plex
|
||||
@using ErsatzTV.Application.Plex
|
||||
@using ErsatzTV.Application.Plex.Commands
|
||||
@@ -16,7 +16,7 @@
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Dense="true" Items="_mediaSources">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Plex Media Source</MudText>
|
||||
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
|
||||
@@ -43,9 +43,10 @@
|
||||
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
|
||||
<MudNavLink Href="/watermarks">Watermarks</MudNavLink>
|
||||
<MudNavGroup Title="Media Sources" Expanded="true">
|
||||
<MudNavLink Href="/media/emby">Emby</MudNavLink>
|
||||
<MudNavLink Href="/media/jellyfin">Jellyfin</MudNavLink>
|
||||
<MudNavLink Href="/media/plex">Plex</MudNavLink>
|
||||
<MudNavLink Href="/media/sources/local">Local</MudNavLink>
|
||||
<MudNavLink Href="/media/sources/emby">Emby</MudNavLink>
|
||||
<MudNavLink Href="/media/sources/jellyfin">Jellyfin</MudNavLink>
|
||||
<MudNavLink Href="/media/sources/plex">Plex</MudNavLink>
|
||||
</MudNavGroup>
|
||||
<MudNavGroup Title="Media" Expanded="true">
|
||||
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.Libraries.Commands
|
||||
@using ErsatzTV.Application.Libraries.Queries
|
||||
@inject IMediator _mediator
|
||||
@inject IMemoryCache _memoryCache
|
||||
@inject ISnackbar _snackbar
|
||||
@inject ILogger<MoveLocalLibraryPathDialog> _logger
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
|
||||
<MudContainer Class="mb-6">
|
||||
<MudText>
|
||||
Select the destination library
|
||||
</MudText>
|
||||
</MudContainer>
|
||||
<MudSelect Label="Library" @bind-Value="_selectedLibrary" Class="mb-6 mx-4">
|
||||
@foreach (LocalLibraryViewModel library in _libraries)
|
||||
{
|
||||
<MudSelectItem Value="@library">@library.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextFieldString Label="New Library Name"
|
||||
Disabled="@(_selectedLibrary != _newLibrary)"
|
||||
@bind-Text="@_newLibraryName"
|
||||
Class="mb-6 mx-4">
|
||||
</MudTextFieldString>
|
||||
</EditForm>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
|
||||
Move To Library
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
|
||||
@code {
|
||||
|
||||
[CascadingParameter]
|
||||
MudDialogInstance MudDialog { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public LibraryMediaKind MediaKind { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int SourceLibraryId { get; set; }
|
||||
|
||||
private LocalLibraryViewModel _newLibrary;
|
||||
private string _newLibraryName;
|
||||
|
||||
private List<LocalLibraryViewModel> _libraries;
|
||||
|
||||
private LocalLibraryViewModel _selectedLibrary;
|
||||
|
||||
private record DummyModel;
|
||||
|
||||
private readonly DummyModel _dummyModel = new();
|
||||
|
||||
private bool CanSubmit() =>
|
||||
_selectedLibrary != null && (_selectedLibrary != _newLibrary || !string.IsNullOrWhiteSpace(_newLibraryName));
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_newLibrary = new LocalLibraryViewModel(-1, "(New Library)", MediaKind);
|
||||
|
||||
_libraries = await _mediator.Send(new GetAllLocalLibraries())
|
||||
.Map(list => list.Filter(ll => ll.MediaKind == MediaKind && ll.Id != SourceLibraryId))
|
||||
.Map(list => new[] { _newLibrary }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList());
|
||||
|
||||
if (_memoryCache.TryGetValue("MoveLocalLibraryPathDialog.SelectedLibraryId", out int id))
|
||||
{
|
||||
_selectedLibrary = _libraries.SingleOrDefault(c => c.Id == id) ?? _newLibrary;
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedLibrary = _newLibrary;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
if (!CanSubmit())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedLibrary == _newLibrary)
|
||||
{
|
||||
Either<BaseError, LocalLibraryViewModel> maybeResult =
|
||||
await _mediator.Send(new CreateLocalLibrary(_newLibraryName, MediaKind, new List<string>()));
|
||||
|
||||
maybeResult.Match(
|
||||
collection =>
|
||||
{
|
||||
_memoryCache.Set("MoveLocalLibraryPathDialog.SelectedLibraryId", collection.Id);
|
||||
MudDialog.Close(DialogResult.Ok(collection));
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add(error.Value, Severity.Error);
|
||||
_logger.LogError("Error creating new local library: {Error}", error.Value);
|
||||
MudDialog.Close(DialogResult.Cancel());
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_memoryCache.Set("MoveLocalLibraryPathDialog.SelectedLibraryId", _selectedLibrary.Id);
|
||||
MudDialog.Close(DialogResult.Ok(_selectedLibrary));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel(MouseEventArgs e)
|
||||
{
|
||||
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
|
||||
if (e.Detail == 0)
|
||||
{
|
||||
await Submit();
|
||||
}
|
||||
else
|
||||
{
|
||||
MudDialog.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
_navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}");
|
||||
_navigationManager.NavigateTo($"/media/sources/{Name.ToLowerInvariant()}");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Dense="true" Items="_mediaSources">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">@Name Media Source</MudText>
|
||||
<MudText Typo="Typo.h6">@Name Media Sources</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.ViewModels;
|
||||
using FluentValidation;
|
||||
|
||||
namespace ErsatzTV.Validators
|
||||
{
|
||||
public class LocalLibraryEditViewModelValidator : AbstractValidator<LocalLibraryEditViewModel>
|
||||
{
|
||||
public LocalLibraryEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.ViewModels
|
||||
{
|
||||
public class LocalLibraryEditViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public LibraryMediaKind MediaKind { get; set; }
|
||||
public string NewPath { get; set; }
|
||||
public List<LocalLibraryPathEditViewModel> Paths { get; set; }
|
||||
public bool HasChanges { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public class LocalLibraryPathEditViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 9.7 KiB |
+1
-1
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -3,23 +3,30 @@ Four library kinds are currently supported: [Local](#local-libraries), [Plex](#p
|
||||
|
||||
## Local Libraries
|
||||
|
||||
ErsatzTV provides three local libraries, one for each supported media kind: `Movies`, `Shows` and `Music Videos`.
|
||||
Local libraries provide a way to schedule media directly from folders; no media server is necessary.
|
||||
|
||||
### Metadata
|
||||
|
||||
With local libraries, ErsatzTV will read metadata from [NFO files](https://kodi.wiki/view/NFO_files), falling back to a *minimal* amount of metadata if NFO files are not found.
|
||||
|
||||
### Add Libraries
|
||||
|
||||
ErsatzTV provides three local libraries by default, one for each supported media kind: `Movies`, `Shows` and `Music Videos`.
|
||||
Additional libraries can be added under `Media Sources` > `Local`.
|
||||
|
||||
### Add Media Items
|
||||
|
||||
To add media items to a local library under `Media` > `Libraries`, click the edit button for the library:
|
||||
To add media items to a local library under `Media Sources` > `Local`, click the edit button for the library:
|
||||
|
||||

|
||||

|
||||
|
||||
Then click the `Add Library Path` button and enter the path where your media files of the appropriate kind are stored:
|
||||
In the `Path` field, enter the path where your media files of the appropriate kind are stored:
|
||||
|
||||

|
||||
|
||||
Finally, click `Add Local Library Path` and ErsatzTV will scan and import your media items.
|
||||
Finally, click `Add Path`.
|
||||
|
||||
When you have added all of your media paths, click `Save Changes` and ErsatzTV will scan and import your media items.
|
||||
|
||||
## Plex Libraries
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ Channel numbers can be whole numbers or can contain one decimal, like `500` or `
|
||||
|
||||
### Streaming Mode
|
||||
|
||||
Two streaming modes are currently supported: `MPEG-TS` (Transport Stream) and `HLS Direct` (HTTP Live Streaming Direct).
|
||||
`MPEG-TS` is considered stable and is recommended for most purposes.
|
||||
`HLS Direct` is unstable and is not recommended for general use, but can avoid the need to transcode with some clients.
|
||||
Three streaming modes are currently supported: `MPEG-TS` (Transport Stream), `HLS Direct` (HTTP Live Streaming Direct) and `HLS Hybrid` (HTTP Live Streaming Hybrid).
|
||||
* `MPEG-TS` transcodes content and supports watermarks, though some clients will have issues at program boundaries
|
||||
* `HLS Direct` does not transcode content and can perform better on low power systems, but does not support watermarks
|
||||
* `HLS Hybrid` transcodes content and supports watermarks, and can perform very well at program boundaries with some clients
|
||||
|
||||
### FFmpeg Profile
|
||||
|
||||
@@ -24,15 +25,10 @@ Channel logos can be added using the `Upload Logo` button and the logos will dis
|
||||
## Create Schedule
|
||||
|
||||
Schedules are used to control the playback order of media items on a channel.
|
||||
Create a Schedule by navigating to the `Schedules` page, clicking `Add Schedule` and giving your schedule a name, a collection playback order, and clicking `Add Schedule` to confirm your selections.
|
||||
Create a Schedule by navigating to the `Schedules` page, clicking `Add Schedule` and giving your schedule a name, and selecting the appropriate settings:
|
||||
|
||||
### Collection Playback Order
|
||||
|
||||
Select the desired playback order for media items within each collection in the schedule:
|
||||
|
||||
- `Chronological`: Items are ordered by release date, then by season and episode number.
|
||||
- `Random`: Items are randomly ordered and may contain repeats.
|
||||
- `Shuffle`: Items are randomly ordered and no item will be played a second time until every item from the collection has been played once.
|
||||
* `Keep Multi-Part Episodes Together`: This only applies to shuffled schedule items, and will try to intelligently group multi-part episodes (i.e. `s05e02 - whatever part 1` and `s05e03 - whatever part 2`) so they are always scheduled together and always play in the correct order.
|
||||
* `Treat Collections As Shows`: This only applies when `Keep Multi-Part Episodes Together` is enabled, and will try to group multi-part episodes across shows within the collection (i.e. crossover episodes like `Show 1 - s03e04 - Whatever Part 1` and `Show 2 - s01e07 - Whatever Part 2`).
|
||||
|
||||
### Schedule Items
|
||||
|
||||
@@ -56,11 +52,20 @@ Schedule items can contain the following collection types:
|
||||
- `Television Show`: Select an entire television show.
|
||||
- `Television Season`: Select a specific season of a television show.
|
||||
- `Artist`: Select all music videos for a specific artist.
|
||||
- `Multi Collection`: Select all collections within a multi-collection.
|
||||
|
||||
#### Collection
|
||||
|
||||
Based on the selected collection type, select the desired collection.
|
||||
|
||||
### Playback Order
|
||||
|
||||
Select the desired playback order for media items within the selected collection:
|
||||
|
||||
- `Chronological`: Items are ordered by release date, then by season and episode number.
|
||||
- `Random`: Items are randomly ordered and may contain repeats.
|
||||
- `Shuffle`: Items are randomly ordered and no item will be played a second time until every item from the collection has been played once.
|
||||
|
||||
#### Playout Mode
|
||||
|
||||
Select how you want this schedule item to behave every time it is selected for playback.
|
||||
|
||||
Reference in New Issue
Block a user