Browse Source

add plex path replacements

pull/34/head
Jason Dove 5 years ago
parent
commit
4f785d7a61
  1. 13
      ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs
  2. 65
      ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs
  3. 3
      ErsatzTV.Application/Plex/Mapper.cs
  4. 4
      ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs
  5. 8
      ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs
  6. 26
      ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs
  7. 47
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  8. 7
      ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs
  9. 1
      ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs
  10. 11
      ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs
  11. 19
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  12. 6
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  13. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  14. 32
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  15. 42
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  16. 20
      ErsatzTV.Core/Plex/PlexMediaEntry.cs
  17. 19
      ErsatzTV.Core/Plex/PlexMetadataEntry.cs
  18. 11
      ErsatzTV.Core/Plex/PlexPartEntry.cs
  19. 5
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs
  20. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs
  21. 23
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  22. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  23. 1491
      ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs
  24. 40
      ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs
  25. 46
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  26. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs
  27. 59
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  28. 2
      ErsatzTV/Pages/PlexLibrariesEditor.razor
  29. 70
      ErsatzTV/Pages/PlexMediaSources.razor
  30. 135
      ErsatzTV/Pages/PlexPathReplacementsEditor.razor
  31. 2
      ErsatzTV/Startup.cs
  32. 14
      ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs
  33. 9
      ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs

13
ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record UpdatePlexPathReplacements
(
int PlexMediaSourceId,
List<PlexPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>;
public record PlexPathReplacementItem(int Id, string PlexPath, string LocalPath);
}

65
ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Plex.Commands
{
public class
UpdatePlexPathReplacementsHandler : MediatR.IRequestHandler<UpdatePlexPathReplacements, Either<BaseError, Unit>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdatePlexPathReplacements request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(pms => MergePathReplacements(request, pms))
.Bind(v => v.ToEitherAsync());
private Task<Unit> MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource)
{
plexMediaSource.PathReplacements ??= new List<PlexPathReplacement>();
var incoming = request.PathReplacements.Map(Project).ToList();
var toAdd = incoming.Filter(r => r.Id < 1).ToList();
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
plexMediaSource.PathReplacements.AddRange(toAdd);
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
foreach (PlexPathReplacement pathReplacement in toUpdate)
{
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
.IfSome(
pr =>
{
pr.PlexPath = pathReplacement.PlexPath;
pr.LocalPath = pathReplacement.LocalPath;
});
}
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
}
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
private Task<Validation<BaseError, PlexMediaSource>> Validate(UpdatePlexPathReplacements request) =>
PlexMediaSourceMustExist(request);
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
UpdatePlexPathReplacements request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
}
}

3
ErsatzTV.Application/Plex/Mapper.cs

@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex @@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex
internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
internal static PlexPathReplacementViewModel ProjectToViewModel(PlexPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.PlexPath, pathReplacement.LocalPath);
}
}

4
ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Plex
{
public record PlexPathReplacementViewModel(int Id, string PlexPath, string LocalPath);
}

8
ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Plex.Queries
{
public record GetPlexPathReplacementsBySourceId
(int PlexMediaSourceId) : IRequest<List<PlexPathReplacementViewModel>>;
}

26
ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Plex.Mapper;
namespace ErsatzTV.Application.Plex.Queries
{
public class GetPlexPathReplacementsBySourceIdHandler : IRequestHandler<GetPlexPathReplacementsBySourceId,
List<PlexPathReplacementViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public GetPlexPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<List<PlexPathReplacementViewModel>> Handle(
GetPlexPathReplacementsBySourceId request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetPlexPathReplacements(request.PlexMediaSourceId)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

47
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -1,5 +1,8 @@ @@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@ -13,16 +16,19 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -13,16 +16,19 @@ namespace ErsatzTV.Application.Streaming.Queries
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlayoutRepository _playoutRepository;
public GetPlayoutItemProcessByChannelNumberHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository,
IPlayoutRepository playoutRepository,
IMediaSourceRepository mediaSourceRepository,
FFmpegProcessService ffmpegProcessService)
: base(channelRepository, configElementRepository)
{
_playoutRepository = playoutRepository;
_mediaSourceRepository = mediaSourceRepository;
_ffmpegProcessService = ffmpegProcessService;
}
@ -33,9 +39,32 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -33,9 +39,32 @@ namespace ErsatzTV.Application.Streaming.Queries
{
DateTimeOffset now = DateTimeOffset.Now;
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
return maybePlayoutItem.Match<Either<BaseError, Process>>(
playoutItem => _ffmpegProcessService.ForPlayoutItem(ffmpegPath, channel, playoutItem, now),
() =>
return await maybePlayoutItem.Match<Task<Either<BaseError, Process>>>(
async playoutItem =>
{
MediaVersion version = playoutItem.MediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
if (playoutItem.MediaItem is PlexMovie plexMovie)
{
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
}
return _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
channel,
version,
path,
playoutItem.StartOffset,
now);
},
async () =>
{
if (channel.FFmpegProfile.Transcode)
{
@ -46,5 +75,17 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -46,5 +75,17 @@ namespace ErsatzTV.Application.Streaming.Queries
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'");
});
}
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
// TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows)
Option<PlexPathReplacement> maybeReplacement = replacements
.SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar));
return maybeReplacement.Match(
replacement => path.Replace(replacement.PlexPath, replacement.LocalPath),
() => path);
}
}
}

7
ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs

@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata
[TestFixture]
public class FallbackMetadataProviderTests
{
[SetUp]
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider();
private FallbackMetadataProvider _fallbackMetadataProvider;
[Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata
2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
{
(EpisodeMetadata metadata, int episodeNumber) = FallbackMetadataProvider.GetFallbackMetadata(
(EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),

1
ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs

@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain
// public bool IsOwned { get; set; }
public List<PlexConnection> Connections { get; set; }
public List<PlexPathReplacement> PathReplacements { get; set; }
}
}

11
ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class PlexPathReplacement
{
public int Id { get; set; }
public string PlexPath { get; set; }
public string LocalPath { get; set; }
public int PlexMediaSourceId { get; set; }
public PlexMediaSource PlexMediaSource { get; set; }
}
}

19
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg @@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg
public Process ForPlayoutItem(
string ffmpegPath,
Channel channel,
PlayoutItem item,
MediaVersion version,
string path,
DateTimeOffset start,
DateTimeOffset now)
{
MediaVersion version = item.MediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
version,
item.StartOffset,
start,
now);
MediaFile file = version.MediaFiles.Head();
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(playbackSettings.ThreadCount)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInput(file.Path);
.WithInput(path);
playbackSettings.ScaledSize.Match(
scaledSize =>
@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(item.Start + version.Duration - now)
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
}

6
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -1,9 +1,13 @@ @@ -1,9 +1,13 @@
using ErsatzTV.Core.Domain;
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
string GetSortTitle(string title);
}
}

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

@ -12,10 +12,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -12,10 +12,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<MediaSource>> GetAll();
Task<List<PlexMediaSource>> GetAllPlex();
Task<List<PlexLibrary>> GetPlexLibraries(int plexMediaSourceId);
Task<List<PlexPathReplacement>> GetPlexPathReplacements(int plexMediaSourceId);
Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId);
Task<Option<MediaSource>> Get(int id);
Task<Option<PlexMediaSource>> GetPlex(int id);
Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId);
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource);

32
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata @@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata
return GetTelevisionShowMetadata(fileName, metadata);
}
public static Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata @@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
}
public static MovieMetadata GetFallbackMetadata(Movie movie)
public MovieMetadata GetFallbackMetadata(Movie movie)
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata @@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
private static Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
{
try
{
@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata @@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata
return Tuple(metadata, 0);
}
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
{
try
{
@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata @@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private static ShowMetadata GetTelevisionShowMetadata(
string fileName,
ShowMetadata metadata)
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{
try
{

42
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata @@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata
() =>
{
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
metadata.SortTitle = GetSortTitle(metadata.Title);
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata @@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e))
.ToUnit(),
Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default)
};
@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata @@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
});
@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata @@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
movie.MovieMetadata = new List<MovieMetadata> { metadata };
});
@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata @@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
show.ShowMetadata = new List<ShowMetadata> { metadata };
});
@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata @@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(episode);
return _fallbackMetadataProvider.GetFallbackMetadata(episode);
}
}
@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata @@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
return _fallbackMetadataProvider.GetFallbackMetadata(mediaItem);
}
}
@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata @@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata
return null;
}
private static string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
[XmlRoot("movie")]
public class MovieNfo
{

20
ErsatzTV.Core/Plex/PlexMediaEntry.cs

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMediaEntry
{
public int Id { get; set; }
public int Duration { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double AspectRatio { get; set; }
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public string VideoCodec { get; set; }
public string Container { get; set; }
public string VideoFrameRate { get; set; }
public List<PlexPartEntry> Part { get; set; }
}
}

19
ErsatzTV.Core/Plex/PlexMetadataEntry.cs

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMetadataEntry
{
public string Key { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public int Year { get; set; }
public string Tagline { get; set; }
public string Thumb { get; set; }
public string Art { get; set; }
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public List<PlexMediaEntry> Media { get; set; }
}
}

11
ErsatzTV.Core/Plex/PlexPartEntry.cs

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
namespace ErsatzTV.Core.Plex
{
public class PlexPartEntry
{
public int Id { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

5
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs

@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne(c => c.PlexMediaSource)
.HasForeignKey(c => c.PlexMediaSourceId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.PathReplacements)
.WithOne(r => r.PlexMediaSource)
.HasForeignKey(r => r.PlexMediaSourceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexPathReplacementConfiguration : IEntityTypeConfiguration<PlexPathReplacement>
{
public void Configure(EntityTypeBuilder<PlexPathReplacement> builder) => builder.ToTable("PlexPathReplacement");
}
}

23
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -68,6 +68,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -68,6 +68,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
public Task<List<PlexPathReplacement>> GetPlexPathReplacements(int plexMediaSourceId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.Filter(r => r.PlexMediaSourceId == plexMediaSourceId)
.ToListAsync();
}
public Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
@ -93,6 +101,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -93,6 +101,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return context.PlexMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
@ -109,6 +118,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -109,6 +118,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.FromSqlRaw(
@"select ppr.* from LibraryPath lp
inner join PlexLibrary pl ON pl.Id = lp.LibraryId
inner join Library l ON l.Id = pl.Id
inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
plexLibraryPathId)
.ToListAsync();
}
public Task<int> CountMediaItems(int id)
{
using TvContext context = _dbContextFactory.CreateDbContext();

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<LibraryPath> LibraryPaths { get; set; }
public DbSet<PlexLibrary> PlexLibraries { get; set; }
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; }

1491
ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlexPathReplacement : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"PlexPathReplacement",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlexPath = table.Column<string>("TEXT", nullable: true),
LocalPath = table.Column<string>("TEXT", nullable: true),
PlexMediaSourceId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexPathReplacement", x => x.Id);
table.ForeignKey(
"FK_PlexPathReplacement_PlexMediaSource_PlexMediaSourceId",
x => x.PlexMediaSourceId,
"PlexMediaSource",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_PlexPathReplacement_PlexMediaSourceId",
"PlexPathReplacement",
"PlexMediaSourceId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"PlexPathReplacement");
}
}

46
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -596,6 +596,30 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -596,6 +596,30 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexConnection");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexPathReplacement",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("LocalPath")
.HasColumnType("TEXT");
b.Property<int>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("PlexPath")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexPathReplacement");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramSchedule",
b =>
@ -1266,6 +1290,19 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1266,6 +1290,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("PlexMediaSource");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexPathReplacement",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource")
.WithMany("PathReplacements")
.HasForeignKey("PlexMediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlexMediaSource");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItem",
b =>
@ -1572,7 +1609,14 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1572,7 +1609,14 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ShowMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => { b.Navigation("Connections"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.Navigation("Connections");
b.Navigation("PathReplacements");
});
#pragma warning restore 612, 618
}
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs

@ -6,6 +6,5 @@ @@ -6,6 +6,5 @@
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

59
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -4,6 +4,7 @@ using System.Linq; @@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex @@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex
{
public class PlexServerApiClient : IPlexServerApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
}
public async Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
PlexServerAuthToken token)
@ -77,49 +85,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -77,49 +85,7 @@ namespace ErsatzTV.Infrastructure.Plex
_ => None
};
private static PlexPartEntry Project(PlexPartResponse response) =>
new()
{
Id = response.Id,
Key = response.Key,
Duration = response.Duration,
File = response.File,
Size = response.Size
};
private static PlexMediaEntry Project(PlexMediaResponse response) =>
new()
{
Id = response.Id,
Duration = response.Duration,
Bitrate = response.Bitrate,
Width = response.Width,
Height = response.Height,
AspectRatio = response.AspectRatio,
AudioChannels = response.AudioChannels,
AudioCodec = response.AudioCodec,
VideoCodec = response.VideoCodec,
Container = response.Container,
VideoFrameRate = response.VideoFrameRate,
Part = response.Part.Map(Project).ToList()
};
private static PlexMetadataEntry Project(PlexMetadataResponse response) =>
new()
{
Key = response.Key,
Title = response.Title,
Summary = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
Thumb = response.Thumb,
Art = response.Art,
AddedAt = response.AddedAt,
UpdatedAt = response.UpdatedAt,
Media = response.Media.Map(Project).ToList()
};
private static PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
@ -129,6 +95,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -129,6 +95,7 @@ namespace ErsatzTV.Infrastructure.Plex
var metadata = new MovieMetadata
{
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt),
Year = response.Year,
@ -160,7 +127,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -160,7 +127,7 @@ namespace ErsatzTV.Infrastructure.Plex
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
// TODO: aspect ratio
SampleAspectRatio = ConvertToSAR(media.AspectRatio),
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
@ -181,5 +148,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -181,5 +148,9 @@ namespace ErsatzTV.Infrastructure.Plex
return movie;
}
private static string ConvertToSAR(double aspectRatio) => "1:1";
// TODO: fix this with more detailed stats pull from plex for each item
// Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100";
}
}

2
ErsatzTV/Pages/PlexLibrariesEditor.razor

@ -8,7 +8,7 @@ @@ -8,7 +8,7 @@
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Items="_libraries" Dense="true" Class="mt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Libraries</MudText>
</ToolBarContent>

70
ErsatzTV/Pages/PlexMediaSources.razor

@ -8,38 +8,44 @@ @@ -8,38 +8,44 @@
@inject ILogger<PlexMediaSources> Logger
@inject IJSRuntime JsRuntime
<MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Address</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/media/sources/plex/{context.Id}/libraries")">
Edit Libraries
</MudMenuItem>
@* <MudMenuItem Icon="@Filled.Edit" Link="@($"/media/collections/{context.Id}/items")"> *@
@* Edit Path Replacements *@
@* </MudMenuItem> *@
</MudMenu>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4">
Add Plex Media Source
</MudButton>
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Address</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Libraries">
<MudIconButton Icon="@Icons.Material.Filled.VideoLibrary"
Link="@($"/media/sources/plex/{context.Id}/libraries")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Path Replacements">
<MudIconButton Icon="@Icons.Material.Filled.Folder"
Link="@($"/media/sources/plex/{context.Id}/paths")">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4">
Add Plex Media Source
</MudButton>
</MudContainer>
@code {
private List<PlexMediaSourceViewModel> _mediaSources;

135
ErsatzTV/Pages/PlexPathReplacementsEditor.razor

@ -0,0 +1,135 @@ @@ -0,0 +1,135 @@
@page "/media/sources/plex/{Id:int}/paths"
@using ErsatzTV.Application.Plex
@using ErsatzTV.Application.Plex.Commands
@using ErsatzTV.Application.Plex.Queries
@inject NavigationManager NavigationManager
@inject ILogger<ScheduleItemsEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Items="_pathReplacements.OrderBy(r => r.Id)" Dense="true" @bind-SelectedItem="_selectedItem">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Plex Path</MudTh>
<MudTh>Local Path</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Plex Path">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlexPath
</MudText>
</MudTd>
<MudTd DataLabel="Local Path">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.LocalPath
</MudText>
</MudTd>
<MudTd>
<MudTooltip Text="Delete Path Replacement">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemovePathReplacement(context))">
</MudIconButton>
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddPathReplacement())" Class="mt-4">
Add Path Replacement
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
Save Changes
</MudButton>
@if (_selectedItem is not null)
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<MudCard Class="mt-6">
<MudCardContent>
<MudTextField Label="Plex Path"
@bind-Value="@_selectedItem.PlexPath"
For="@(() => _selectedItem.PlexPath)"/>
<MudTextField Class="mt-3"
Label="Local Path"
@bind-Value="@_selectedItem.LocalPath"
For="@(() => _selectedItem.LocalPath)"/>
</MudCardContent>
</MudCard>
</EditForm>
</div>
}
</MudContainer>
@code {
[Parameter]
public int Id { get; set; }
private PlexMediaSourceViewModel _source;
private List<PlexPathReplacementEditViewModel> _pathReplacements;
private PlexPathReplacementEditViewModel _selectedItem;
protected override Task OnParametersSetAsync() => LoadData();
private async Task LoadData()
{
Option<PlexMediaSourceViewModel> maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id));
await maybeSource.Match(
async source =>
{
_source = source;
_pathReplacements = await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id))
.Map(list => list.Map(ProjectToEditViewModel).ToList());
},
() =>
{
NavigationManager.NavigateTo("404");
return Task.CompletedTask;
});
}
private PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) =>
new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath };
private void AddPathReplacement()
{
var item = new PlexPathReplacementEditViewModel();
_pathReplacements.Add(item);
_selectedItem = item;
}
private void RemovePathReplacement(PlexPathReplacementEditViewModel item)
{
_selectedItem = null;
_pathReplacements.Remove(item);
}
private async Task SaveChanges()
{
var items = _pathReplacements
.Map(item => new PlexPathReplacementItem(item.Id, item.PlexPath, item.LocalPath))
.ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new UpdatePlexPathReplacements(Id, items)).Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving path replacements: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/media/plex"));
}
}

2
ErsatzTV/Startup.cs

@ -178,7 +178,6 @@ namespace ErsatzTV @@ -178,7 +178,6 @@ namespace ErsatzTV
services.AddSingleton<FFmpegProcessService>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<IPlexServerApiClient, PlexServerApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
@ -206,6 +205,7 @@ namespace ErsatzTV @@ -206,6 +205,7 @@ namespace ErsatzTV
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

14
ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class PlexPathReplacementEditViewModelValidator : AbstractValidator<PlexPathReplacementEditViewModel>
{
public PlexPathReplacementEditViewModelValidator()
{
RuleFor(vm => vm.PlexPath).NotEmpty();
RuleFor(vm => vm.LocalPath).NotEmpty();
}
}
}

9
ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.ViewModels
{
public class PlexPathReplacementEditViewModel
{
public int Id { get; set; }
public string PlexPath { get; set; }
public string LocalPath { get; set; }
}
}
Loading…
Cancel
Save