Compare commits

..
Author SHA1 Message Date
Jason Dove c39654ca40 update changelog for release 52 [no ci] 2021-07-22 11:26:05 -05:00
Jason DoveandGitHub f17151bd20 fix multi-collection bug (#318) 2021-07-22 11:12:14 -05:00
Jason Dove 6aeaf65a13 update docs [no docker] 2021-07-20 20:13:21 -05:00
Jason DoveandGitHub 9fbe950e6e support multiple local libraries (#317)
* allow multiple local libraries

* add "move library path" function
2021-07-20 07:47:12 -05:00
Jason DoveandGitHub c9baff2cd5 add codeql workflow [no docker] 2021-07-18 07:36:41 -05:00
42 changed files with 1113 additions and 113 deletions
+71
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+1 -10
View File
@@ -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>
+99
View File
@@ -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;
}
}
+182 -16
View File
@@ -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;
}
}
+2 -2
View File
@@ -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/>
+4 -3
View File
@@ -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()}");
});
}
+1 -1
View File
@@ -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
View File
@@ -1,4 +1,4 @@
![ErsatzTV](../images/ersatztv.png)
![ErsatzTV](images/ersatztv.png)
**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.
+12 -5
View File
@@ -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:
![Libraries Edit Icon](../images/libraries-edit-icon.png)
![Local Libraries Edit Icon](../images/local-libraries-edit-icon.png)
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:
![Add Local Library Path](../images/shows-add-local-library-path.png)
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
+16 -11
View File
@@ -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.