Browse Source

jellyfin and emby path infos (#771)

* start adding jellyfin path info; fix some scanning bugs

* sync jellyfin libraries before scanning to ensure latest path infos

* code cleanup

* support emby path infos

* fix periodic scanning of emby and jellyfin libraries

* bug fixes
pull/773/head
Jason Dove 4 years ago committed by GitHub
parent
commit
404ea49e35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 30
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibrariesHandler.cs
  3. 9
      ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs
  4. 2
      ErsatzTV.Application/Emby/Mapper.cs
  5. 34
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibrariesHandler.cs
  6. 9
      ErsatzTV.Application/Jellyfin/JellyfinLibraryViewModel.cs
  7. 2
      ErsatzTV.Application/Jellyfin/Mapper.cs
  8. 2
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
  9. 2
      ErsatzTV.Application/Libraries/LibraryViewModel.cs
  10. 4
      ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs
  11. 13
      ErsatzTV.Application/Libraries/Mapper.cs
  12. 4
      ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs
  13. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  14. 22
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs
  15. 24
      ErsatzTV.Core.Tests/Emby/EmbyPathReplacementServiceTests.cs
  16. 31
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  17. 24
      ErsatzTV.Core.Tests/Jellyfin/JellyfinPathReplacementServiceTests.cs
  18. 3
      ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs
  19. 3
      ErsatzTV.Core/Domain/ChannelSubtitleMode.cs
  20. 5
      ErsatzTV.Core/Domain/Library/EmbyLibrary.cs
  21. 5
      ErsatzTV.Core/Domain/Library/JellyfinLibrary.cs
  22. 2
      ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs
  23. 8
      ErsatzTV.Core/Emby/EmbyPathInfo.cs
  24. 44
      ErsatzTV.Core/Emby/EmbyPathReplacementService.cs
  25. 1
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  26. 3
      ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs
  27. 1
      ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs
  28. 5
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs
  29. 1
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs
  30. 6
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  31. 3
      ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  32. 8
      ErsatzTV.Core/Jellyfin/JellyfinPathInfo.cs
  33. 43
      ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs
  34. 2
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  35. 10
      ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs
  36. 10
      ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  37. 8
      ErsatzTV.Infrastructure/Data/Configurations/Library/EmbyLibraryConfiguration.cs
  38. 8
      ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs
  39. 4
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  40. 12
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  41. 4
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  42. 12
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  43. 459
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  44. 4
      ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs
  45. 12
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  46. 62
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  47. 6
      ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryOptionsResponse.cs
  48. 1
      ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryResponse.cs
  49. 7
      ErsatzTV.Infrastructure/Emby/Models/EmbyPathInfosResponse.cs
  50. 4
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  51. 68
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  52. 6
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryOptionsResponse.cs
  53. 1
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryResponse.cs
  54. 7
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPathInfosResponse.cs
  55. 4175
      ErsatzTV.Infrastructure/Migrations/20220429003054_Add_JellyfinLibraryPathInfos.Designer.cs
  56. 44
      ErsatzTV.Infrastructure/Migrations/20220429003054_Add_JellyfinLibraryPathInfos.cs
  57. 4210
      ErsatzTV.Infrastructure/Migrations/20220429153234_Add_EmbyLibraryPathInfos.Designer.cs
  58. 44
      ErsatzTV.Infrastructure/Migrations/20220429153234_Add_EmbyLibraryPathInfos.cs
  59. 4210
      ErsatzTV.Infrastructure/Migrations/20220429163623_Rescan_EmbyJellyfinLibrariesPathInfos.Designer.cs
  60. 30
      ErsatzTV.Infrastructure/Migrations/20220429163623_Rescan_EmbyJellyfinLibrariesPathInfos.cs
  61. 70
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  62. 5
      ErsatzTV/Pages/EmbyLibrariesEditor.razor
  63. 5
      ErsatzTV/Pages/JellyfinLibrariesEditor.razor
  64. 2
      ErsatzTV/Pages/Libraries.razor
  65. 4
      ErsatzTV/Pages/PlexLibrariesEditor.razor
  66. 33
      ErsatzTV/Services/EmbyService.cs
  67. 4
      ErsatzTV/Services/JellyfinService.cs
  68. 55
      ErsatzTV/Services/SchedulerService.cs
  69. 2
      ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor
  70. 10
      ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor

8
CHANGELOG.md

@ -10,15 +10,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix ability of health check crash to crash home page - Fix ability of health check crash to crash home page
- Remove and ignore Season 0/Specials from Plex shows that have no specials - Remove and ignore Season 0/Specials from Plex shows that have no specials
- Automatically delete and rebuild the search index on startup if it has become corrupt - Automatically delete and rebuild the search index on startup if it has become corrupt
- Automatically scan Jellyfin and Emby libraries on startup and periodically
- Properly remove un-synchronized Plex, Jellyfin and Emby items from the database and search index
- Fix synchronizing movies within a collection from Jellyfin
### Changed ### Changed
- Update Plex, Jellyfin and Emby movie and show library scanners to share a significant amount of code - Update Plex, Jellyfin and Emby movie and show library scanners to share a significant amount of code
- This should help maintain feature parity going forward - This should help maintain feature parity going forward
- Jellyfin and Emby movie and show library scanners now support the `unavailable` media state
- Optimize search-index rebuilding to complete 100x faster - Optimize search-index rebuilding to complete 100x faster
- **No longer use network paths to source content from Jellyfin and Emby**
- **If you previously used path replacements to convert network paths to local paths, you should remove them**
### Added ### Added
- Add `unavailable` state for Emby movie libraries - Add `unavailable` state for Jellyfin and Emby movie and show libraries
- Add `height` and `width` to search index for all videos - Add `height` and `width` to search index for all videos
- Add `season_number` and `episode_number` to search index for all episodes - Add `season_number` and `episode_number` to search index for all episodes
- Add `season_number` to search index for seasons - Add `season_number` to search index for seasons

30
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibrariesHandler.cs

@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
public class public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
{ {
private readonly IEmbyApiClient _embyApiClient; private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbySecretStore _embySecretStore; private readonly IEmbySecretStore _embySecretStore;
@ -72,32 +71,33 @@ public class
connectionParameters.ActiveConnection.Address, connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey); connectionParameters.ApiKey);
await maybeLibraries.Match( foreach (BaseError error in maybeLibraries.LeftToSeq())
async libraries => {
_logger.LogWarning(
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
connectionParameters.EmbyMediaSource.ServerName,
error.Value);
}
foreach (List<EmbyLibrary> libraries in maybeLibraries.RightToSeq())
{ {
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>() var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
.ToList(); .ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries( List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.EmbyMediaSource.Id, connectionParameters.EmbyMediaSource.Id,
toAdd, toAdd,
toRemove); toRemove,
toUpdate);
if (ids.Any()) if (ids.Any())
{ {
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit(); _searchIndex.Commit();
} }
}, }
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
connectionParameters.EmbyMediaSource.ServerName,
error.Value);
return Task.CompletedTask;
});
return Unit.Default; return Unit.Default;
} }

9
ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs

@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Emby; namespace ErsatzTV.Application.Emby;
public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems) public record EmbyLibraryViewModel(
: LibraryViewModel("Emby", Id, Name, MediaKind); int Id,
string Name,
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId);

2
ErsatzTV.Application/Emby/Mapper.cs

@ -11,7 +11,7 @@ internal static class Mapper
embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty)); embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) => internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) => internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath); new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath);

34
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibrariesHandler.cs

@ -9,9 +9,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
public class public class
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries, SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries, Either<BaseError, Unit>>
Either<BaseError, Unit>>
{ {
private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinSecretStore _jellyfinSecretStore;
@ -74,32 +72,34 @@ public class
connectionParameters.ActiveConnection.Address, connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey); connectionParameters.ApiKey);
await maybeLibraries.Match( foreach (BaseError error in maybeLibraries.LeftToSeq())
async libraries => {
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
error.Value);
}
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
{ {
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>() var existing = connectionParameters.JellyfinMediaSource.Libraries
.OfType<JellyfinLibrary>()
.ToList(); .ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries( List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id, connectionParameters.JellyfinMediaSource.Id,
toAdd, toAdd,
toRemove); toRemove,
toUpdate);
if (ids.Any()) if (ids.Any())
{ {
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit(); _searchIndex.Commit();
} }
}, }
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
error.Value);
return Task.CompletedTask;
});
return Unit.Default; return Unit.Default;
} }

9
ErsatzTV.Application/Jellyfin/JellyfinLibraryViewModel.cs

@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Jellyfin; namespace ErsatzTV.Application.Jellyfin;
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems) public record JellyfinLibraryViewModel(
: LibraryViewModel("Jellyfin", Id, Name, MediaKind); int Id,
string Name,
LibraryMediaKind MediaKind,
bool ShouldSyncItems,
int MediaSourceId)
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId);

2
ErsatzTV.Application/Jellyfin/Mapper.cs

@ -11,7 +11,7 @@ internal static class Mapper
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty)); jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) => internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) => internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath); new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);

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

@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request); Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary)); return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
} }
private async Task<LocalLibraryViewModel> PersistLocalLibrary( private async Task<LocalLibraryViewModel> PersistLocalLibrary(

2
ErsatzTV.Application/Libraries/LibraryViewModel.cs

@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind); public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId);

4
ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs

@ -2,5 +2,5 @@
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind) public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Local", Id, Name, MediaKind); : LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId);

13
ErsatzTV.Application/Libraries/Mapper.cs

@ -10,14 +10,19 @@ internal static class Mapper
library switch library switch
{ {
LocalLibrary l => ProjectToViewModel(l), LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind), PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind, p.MediaSourceId),
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems), JellyfinLibrary j => new JellyfinLibraryViewModel(
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems), j.Id,
j.Name,
j.MediaKind,
j.ShouldSyncItems,
j.MediaSourceId),
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems, e.MediaSourceId),
_ => throw new ArgumentOutOfRangeException(nameof(library)) _ => throw new ArgumentOutOfRangeException(nameof(library))
}; };
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) => public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
new(library.Id, library.Name, library.MediaKind); new(library.Id, library.Name, library.MediaKind, library.MediaSourceId);
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) => public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path); new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);

4
ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs

@ -2,5 +2,5 @@
namespace ErsatzTV.Application.Libraries; namespace ErsatzTV.Application.Libraries;
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind) public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Plex", Id, Name, MediaKind); : LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId);

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -148,7 +148,7 @@ internal static class Mapper
Collection collection, Collection collection,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) => Option<EmbyMediaSource> maybeEmby) =>
new CollectionCardResultsViewModel( new(
collection.Name, collection.Name,
collection.MediaItems.OfType<Movie>().Map( collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with

22
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs

@ -71,8 +71,15 @@ public class
connectionParameters.ActiveConnection, connectionParameters.ActiveConnection,
connectionParameters.PlexServerAuthToken); connectionParameters.PlexServerAuthToken);
await maybeLibraries.Match( foreach (BaseError error in maybeLibraries.LeftToSeq())
async libraries => {
_logger.LogWarning(
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.ServerName,
error.Value);
}
foreach (List<PlexLibrary> libraries in maybeLibraries.RightToSeq())
{ {
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList(); var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList(); var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
@ -86,16 +93,7 @@ public class
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit(); _searchIndex.Commit();
} }
}, }
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.ServerName,
error.Value);
return Task.CompletedTask;
});
return Unit.Default; return Unit.Default;
} }

24
ErsatzTV.Core.Tests/Emby/EmbyPathReplacementServiceTests.cs

@ -77,6 +77,30 @@ public class EmbyPathReplacementServiceTests
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv"); result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
} }
[Test]
public void EmbyWindows_To_EtvLinux_NetworkPath()
{
var mediaSource = new EmbyMediaSource { OperatingSystem = "Windows" };
var repo = new Mock<IMediaSourceRepository>();
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new EmbyPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<EmbyPathReplacementService>>().Object);
string result = service.ReplaceNetworkPath(
mediaSource,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
@"\\192.168.1.100\Something\Some Shared Folder",
@"C:\mnt\something else\Some Shared Folder");
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test] [Test]
public async Task EmbyWindows_To_EtvLinux_UncPath() public async Task EmbyWindows_To_EtvLinux_UncPath()
{ {

31
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -60,6 +60,22 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow( public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library, PlexLibrary library,
PlexShow item) => PlexShow item) =>
@ -73,14 +89,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
PlexEpisode item) => PlexEpisode item) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) => public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException(); throw new NotSupportedException();
@ -90,13 +98,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) => public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
throw new NotSupportedException();
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException(); public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException(); public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();

24
ErsatzTV.Core.Tests/Jellyfin/JellyfinPathReplacementServiceTests.cs

@ -77,6 +77,30 @@ public class JellyfinPathReplacementServiceTests
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv"); result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
} }
[Test]
public void JellyfinWindows_To_EtvLinux_NetworkPath()
{
var mediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" };
var repo = new Mock<IMediaSourceRepository>();
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = service.ReplaceNetworkPath(
mediaSource,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
@"\\192.168.1.100\Something\Some Shared Folder",
@"C:\mnt\something else\Some Shared Folder");
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test] [Test]
public async Task JellyfinWindows_To_EtvLinux_UncPath() public async Task JellyfinWindows_To_EtvLinux_UncPath()
{ {

3
ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs

@ -6,6 +6,7 @@ public record ChannelResponseModel(
int Id, int Id,
string Number, string Number,
string Name, string Name,
[property: JsonProperty("ffmpegProfile")] string FFmpegProfile, [property: JsonProperty("ffmpegProfile")]
string FFmpegProfile,
string Language, string Language,
string StreamingMode); string StreamingMode);

3
ErsatzTV.Core/Domain/ChannelSubtitleMode.cs

@ -5,4 +5,5 @@ public enum ChannelSubtitleMode
None = 0, None = 0,
Forced = 1, Forced = 1,
Default = 2, Default = 2,
Any = 3} Any = 3
}

5
ErsatzTV.Core/Domain/Library/EmbyLibrary.cs

@ -1,7 +1,10 @@
namespace ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby;
namespace ErsatzTV.Core.Domain;
public class EmbyLibrary : Library public class EmbyLibrary : Library
{ {
public string ItemId { get; set; } public string ItemId { get; set; }
public bool ShouldSyncItems { get; set; } public bool ShouldSyncItems { get; set; }
public List<EmbyPathInfo> PathInfos { get; set; }
} }

5
ErsatzTV.Core/Domain/Library/JellyfinLibrary.cs

@ -1,7 +1,10 @@
namespace ErsatzTV.Core.Domain; using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Domain;
public class JellyfinLibrary : Library public class JellyfinLibrary : Library
{ {
public string ItemId { get; set; } public string ItemId { get; set; }
public bool ShouldSyncItems { get; set; } public bool ShouldSyncItems { get; set; }
public List<JellyfinPathInfo> PathInfos { get; set; }
} }

2
ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs

@ -85,7 +85,7 @@ public class EmbyMovieLibraryScanner :
_embyApiClient.GetMovieLibraryItems( _embyApiClient.GetMovieLibraryItems(
connectionParameters.Address, connectionParameters.Address,
connectionParameters.ApiKey, connectionParameters.ApiKey,
library.ItemId); library);
protected override Task<Option<MovieMetadata>> GetFullMetadata( protected override Task<Option<MovieMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters, EmbyConnectionParameters connectionParameters,

8
ErsatzTV.Core/Emby/EmbyPathInfo.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Emby;
public class EmbyPathInfo
{
public int Id { get; set; }
public string Path { get; set; }
public string NetworkPath { get; set; }
}

44
ErsatzTV.Core/Emby/EmbyPathReplacementService.cs

@ -31,7 +31,39 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
return GetReplacementEmbyPath(replacements, path, log); return GetReplacementEmbyPath(replacements, path, log);
} }
public string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true) public string GetReplacementEmbyPath(
List<EmbyPathReplacement> pathReplacements,
string path,
bool log = true) =>
GetReplacementEmbyPath(pathReplacements, path, _runtimeInfo.IsOSPlatform(OSPlatform.Windows), log);
public string ReplaceNetworkPath(
EmbyMediaSource embyMediaSource,
string path,
string networkPath,
string replacement)
{
var replacements = new List<EmbyPathReplacement>
{
new() { EmbyPath = networkPath, LocalPath = replacement, EmbyMediaSource = embyMediaSource }
};
// we want to target the emby platform with the network path replacement
bool isTargetPlatformWindows = embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
return GetReplacementEmbyPath(replacements, path, isTargetPlatformWindows, false);
}
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
private string GetReplacementEmbyPath(
List<EmbyPathReplacement> pathReplacements,
string path,
bool isTargetPlatformWindows,
bool log)
{ {
Option<EmbyPathReplacement> maybeReplacement = pathReplacements Option<EmbyPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault( .SingleOrDefault(
@ -50,11 +82,11 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
foreach (EmbyPathReplacement replacement in maybeReplacement) foreach (EmbyPathReplacement replacement in maybeReplacement)
{ {
string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath); string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath);
if (IsWindows(replacement.EmbyMediaSource, path) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) if (IsWindows(replacement.EmbyMediaSource, path) && !isTargetPlatformWindows)
{ {
finalPath = finalPath.Replace(@"\", @"/"); finalPath = finalPath.Replace(@"\", @"/");
} }
else if (!IsWindows(replacement.EmbyMediaSource, path) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows)) else if (!IsWindows(replacement.EmbyMediaSource, path) && isTargetPlatformWindows)
{ {
finalPath = finalPath.Replace(@"/", @"\"); finalPath = finalPath.Replace(@"/", @"\");
} }
@ -73,10 +105,4 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
return path; return path;
} }
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
} }

1
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -108,6 +108,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
_embyApiClient.GetEpisodeLibraryItems( _embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address, connectionParameters.Address,
connectionParameters.ApiKey, connectionParameters.ApiKey,
library,
season.ItemId); season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata( protected override Task<Option<ShowMetadata>> GetFullMetadata(

3
ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs

@ -11,7 +11,7 @@ public interface IEmbyApiClient
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems( Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address, string address,
string apiKey, string apiKey,
string libraryId); EmbyLibrary library);
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems( Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
string address, string address,
@ -26,6 +26,7 @@ public interface IEmbyApiClient
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems( Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
string address, string address,
string apiKey, string apiKey,
EmbyLibrary library,
string seasonId); string seasonId);
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems( Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(

1
ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs

@ -6,4 +6,5 @@ public interface IEmbyPathReplacementService
{ {
Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true); Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true);
string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true); string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true);
string ReplaceNetworkPath(EmbyMediaSource embyMediaSource, string path, string networkPath, string replacement);
} }

5
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs

@ -12,8 +12,7 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems( Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address, string address,
string apiKey, string apiKey,
int mediaSourceId, JellyfinLibrary library);
string libraryId);
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems( Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
string address, string address,
@ -30,7 +29,7 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems( Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
string address, string address,
string apiKey, string apiKey,
int mediaSourceId, JellyfinLibrary library,
string seasonId); string seasonId);
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems( Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(

1
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs

@ -6,4 +6,5 @@ public interface IJellyfinPathReplacementService
{ {
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true); Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true);
string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true); string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true);
string ReplaceNetworkPath(JellyfinMediaSource mediaSource, string path, string networkPath, string replacement);
} }

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

@ -26,12 +26,14 @@ public interface IMediaSourceRepository
Task<List<int>> UpdateLibraries( Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId, int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd, List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete); List<JellyfinLibrary> toDelete,
List<JellyfinLibrary> toUpdate);
Task<List<int>> UpdateLibraries( Task<List<int>> UpdateLibraries(
int embyMediaSourceId, int embyMediaSourceId,
List<EmbyLibrary> toAdd, List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete); List<EmbyLibrary> toDelete,
List<EmbyLibrary> toUpdate);
Task<Unit> UpdatePathReplacements( Task<Unit> UpdatePathReplacements(
int plexMediaSourceId, int plexMediaSourceId,

3
ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -86,8 +86,7 @@ public class JellyfinMovieLibraryScanner :
_jellyfinApiClient.GetMovieLibraryItems( _jellyfinApiClient.GetMovieLibraryItems(
connectionParameters.Address, connectionParameters.Address,
connectionParameters.ApiKey, connectionParameters.ApiKey,
connectionParameters.MediaSourceId, library);
library.ItemId);
protected override Task<Option<MovieMetadata>> GetFullMetadata( protected override Task<Option<MovieMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters, JellyfinConnectionParameters connectionParameters,

8
ErsatzTV.Core/Jellyfin/JellyfinPathInfo.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Jellyfin;
public class JellyfinPathInfo
{
public int Id { get; set; }
public string Path { get; set; }
public string NetworkPath { get; set; }
}

43
ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs

@ -34,7 +34,36 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
public string GetReplacementJellyfinPath( public string GetReplacementJellyfinPath(
List<JellyfinPathReplacement> pathReplacements, List<JellyfinPathReplacement> pathReplacements,
string path, string path,
bool log = true) bool log = true) =>
GetReplacementJellyfinPath(pathReplacements, path, _runtimeInfo.IsOSPlatform(OSPlatform.Windows), log);
public string ReplaceNetworkPath(
JellyfinMediaSource jellyfinMediaSource,
string path,
string networkPath,
string replacement)
{
var replacements = new List<JellyfinPathReplacement>
{
new() { JellyfinPath = networkPath, LocalPath = replacement, JellyfinMediaSource = jellyfinMediaSource }
};
// we want to target the jellyfin platform with the network path replacement
bool isTargetPlatformWindows = jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
return GetReplacementJellyfinPath(replacements, path, isTargetPlatformWindows, false);
}
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
private string GetReplacementJellyfinPath(
List<JellyfinPathReplacement> pathReplacements,
string path,
bool isTargetPlatformWindows,
bool log)
{ {
Option<JellyfinPathReplacement> maybeReplacement = pathReplacements Option<JellyfinPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault( .SingleOrDefault(
@ -55,13 +84,11 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
foreach (JellyfinPathReplacement replacement in maybeReplacement) foreach (JellyfinPathReplacement replacement in maybeReplacement)
{ {
string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath); string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath);
if (IsWindows(replacement.JellyfinMediaSource, path) && if (IsWindows(replacement.JellyfinMediaSource, path) && !isTargetPlatformWindows)
!_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{ {
finalPath = finalPath.Replace(@"\", @"/"); finalPath = finalPath.Replace(@"\", @"/");
} }
else if (!IsWindows(replacement.JellyfinMediaSource, path) && else if (!IsWindows(replacement.JellyfinMediaSource, path) && isTargetPlatformWindows)
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{ {
finalPath = finalPath.Replace(@"/", @"\"); finalPath = finalPath.Replace(@"/", @"\");
} }
@ -80,10 +107,4 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
return path; return path;
} }
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource, string path)
{
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
return isUnc || jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
}
} }

2
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -111,7 +111,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
_jellyfinApiClient.GetEpisodeLibraryItems( _jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address, connectionParameters.Address,
connectionParameters.ApiKey, connectionParameters.ApiKey,
library.MediaSourceId, library,
season.ItemId); season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata( protected override Task<Option<ShowMetadata>> GetFullMetadata(

10
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -209,18 +209,18 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
Option<TEtag> maybeExisting = Option<TEtag> maybeExisting =
existingMovies.Find(m => m.MediaServerItemId == MediaServerItemId(incoming)); existingMovies.Find(m => m.MediaServerItemId == MediaServerItemId(incoming));
string existingItemId = await maybeExisting.Map(e => e.MediaServerItemId).IfNoneAsync(string.Empty); string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
if (existingState == MediaItemState.Unavailable) if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable items that still don't exist locally // skip scanning unavailable items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
return false; return false;
} }
} }
else if (existingItemId == MediaServerItemId(incoming)) else if (existingEtag == MediaServerEtag(incoming))
{ {
// item is unchanged, but file does not exist // item is unchanged, but file does not exist
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
@ -277,7 +277,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{ {
TMovie existing = result.Item; TMovie existing = result.Item;
if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) || if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (_localFileSystem.FileExists(result.LocalPath)) if (_localFileSystem.FileExists(result.LocalPath))

10
ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -439,18 +439,18 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
} }
Option<TEtag> maybeExisting = existingEpisodes.Find(m => m.MediaServerItemId == MediaServerItemId(incoming)); Option<TEtag> maybeExisting = existingEpisodes.Find(m => m.MediaServerItemId == MediaServerItemId(incoming));
string existingItemId = await maybeExisting.Map(e => e.MediaServerItemId).IfNoneAsync(string.Empty); string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal); MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
if (existingState == MediaItemState.Unavailable) if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable items that still don't exist locally // skip scanning unavailable items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
return false; return false;
} }
} }
else if (existingItemId == MediaServerItemId(incoming)) else if (existingEtag == MediaServerEtag(incoming))
{ {
// item is unchanged, but file does not exist // item is unchanged, but file does not exist
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
@ -559,7 +559,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{ {
TEpisode existing = result.Item; TEpisode existing = result.Item;
if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) || if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
if (_localFileSystem.FileExists(result.LocalPath)) if (_localFileSystem.FileExists(result.LocalPath))

8
ErsatzTV.Infrastructure/Data/Configurations/Library/EmbyLibraryConfiguration.cs

@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class EmbyLibraryConfiguration : IEntityTypeConfiguration<EmbyLibrary> public class EmbyLibraryConfiguration : IEntityTypeConfiguration<EmbyLibrary>
{ {
public void Configure(EntityTypeBuilder<EmbyLibrary> builder) => public void Configure(EntityTypeBuilder<EmbyLibrary> builder)
{
builder.ToTable("EmbyLibrary"); builder.ToTable("EmbyLibrary");
builder.HasMany(l => l.PathInfos)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
} }

8
ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs

@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary> public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary>
{ {
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder) => public void Configure(EntityTypeBuilder<JellyfinLibrary> builder)
{
builder.ToTable("JellyfinLibrary"); builder.ToTable("JellyfinLibrary");
builder.HasMany(l => l.PathInfos)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
} }

4
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -152,6 +152,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = movie.Etag;
movie.Etag = string.Empty; movie.Etag = string.Empty;
movie.LibraryPathId = library.Paths.Head().Id; movie.LibraryPathId = library.Paths.Head().Id;
@ -159,6 +160,9 @@ public class EmbyMovieRepository : IEmbyMovieRepository
await dbContext.AddAsync(movie); await dbContext.AddAsync(movie);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
movie.Etag = etag;
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyMovie>(movie) { IsAdded = true }; return new MediaItemScanResult<EmbyMovie>(movie) { IsAdded = true };

12
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -657,6 +657,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = show.Etag;
show.Etag = string.Empty; show.Etag = string.Empty;
show.LibraryPathId = library.Paths.Head().Id; show.LibraryPathId = library.Paths.Head().Id;
@ -664,6 +665,9 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
await dbContext.AddAsync(show); await dbContext.AddAsync(show);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
show.Etag = etag;
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyShow>(show) { IsAdded = true }; return new MediaItemScanResult<EmbyShow>(show) { IsAdded = true };
@ -682,6 +686,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = season.Etag;
season.Etag = string.Empty; season.Etag = string.Empty;
season.LibraryPathId = library.Paths.Head().Id; season.LibraryPathId = library.Paths.Head().Id;
@ -689,6 +694,9 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
await dbContext.AddAsync(season); await dbContext.AddAsync(season);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
season.Etag = etag;
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbySeason>(season) { IsAdded = true }; return new MediaItemScanResult<EmbySeason>(season) { IsAdded = true };
@ -707,6 +715,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = episode.Etag;
episode.Etag = string.Empty; episode.Etag = string.Empty;
episode.LibraryPathId = library.Paths.Head().Id; episode.LibraryPathId = library.Paths.Head().Id;
@ -714,6 +723,9 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
await dbContext.AddAsync(episode); await dbContext.AddAsync(episode);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
episode.Etag = etag;
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyEpisode>(episode) { IsAdded = true }; return new MediaItemScanResult<EmbyEpisode>(episode) { IsAdded = true };

4
ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs

@ -334,6 +334,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = movie.Etag;
movie.Etag = string.Empty; movie.Etag = string.Empty;
movie.LibraryPathId = library.Paths.Head().Id; movie.LibraryPathId = library.Paths.Head().Id;
@ -341,6 +342,9 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
await dbContext.AddAsync(movie); await dbContext.AddAsync(movie);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
movie.Etag = etag;
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinMovie>(movie) { IsAdded = true }; return new MediaItemScanResult<JellyfinMovie>(movie) { IsAdded = true };

12
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -661,6 +661,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = show.Etag;
show.Etag = string.Empty; show.Etag = string.Empty;
show.LibraryPathId = library.Paths.Head().Id; show.LibraryPathId = library.Paths.Head().Id;
@ -668,6 +669,9 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
await dbContext.AddAsync(show); await dbContext.AddAsync(show);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
show.Etag = etag;
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinShow>(show) { IsAdded = true }; return new MediaItemScanResult<JellyfinShow>(show) { IsAdded = true };
@ -686,6 +690,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = season.Etag;
season.Etag = string.Empty; season.Etag = string.Empty;
season.LibraryPathId = library.Paths.Head().Id; season.LibraryPathId = library.Paths.Head().Id;
@ -693,6 +698,9 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
await dbContext.AddAsync(season); await dbContext.AddAsync(season);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
season.Etag = etag;
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinSeason>(season) { IsAdded = true }; return new MediaItemScanResult<JellyfinSeason>(season) { IsAdded = true };
@ -711,6 +719,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
try try
{ {
// blank out etag for initial save in case other updates fail // blank out etag for initial save in case other updates fail
string etag = episode.Etag;
episode.Etag = string.Empty; episode.Etag = string.Empty;
episode.LibraryPathId = library.Paths.Head().Id; episode.LibraryPathId = library.Paths.Head().Id;
@ -718,6 +727,9 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
await dbContext.AddAsync(episode); await dbContext.AddAsync(episode);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
episode.Etag = etag;
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinEpisode>(episode) { IsAdded = true }; return new MediaItemScanResult<JellyfinEpisode>(episode) { IsAdded = true };

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

@ -1,6 +1,10 @@
using Dapper; using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -159,26 +163,50 @@ public class MediaSourceRepository : IMediaSourceRepository
public async Task<List<int>> UpdateLibraries( public async Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId, int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd, List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete) List<JellyfinLibrary> toDelete,
List<JellyfinLibrary> toUpdate)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (JellyfinLibrary add in toAdd) foreach (JellyfinLibrary add in toAdd)
{ {
add.MediaSourceId = jellyfinMediaSourceId; add.MediaSourceId = jellyfinMediaSourceId;
dbContext.Entry(add).State = EntityState.Added; dbContext.JellyfinLibraries.Add(add);
foreach (LibraryPath path in add.Paths) }
dbContext.JellyfinLibraries.RemoveRange(toDelete);
List<int> ids = await DisableJellyfinLibrarySync(toDelete.Map(l => l.Id).ToList());
foreach (JellyfinLibrary incoming in toUpdate)
{ {
dbContext.Entry(path).State = EntityState.Added; Option<JellyfinLibrary> maybeExisting = await dbContext.JellyfinLibraries
.Include(l => l.PathInfos)
.SelectOneAsync(l => l.ItemId, l => l.ItemId == incoming.ItemId);
foreach (JellyfinLibrary existing in maybeExisting)
{
// remove paths that are not on the incoming version
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
// update all remaining paths
foreach (JellyfinPathInfo existingPathInfo in existing.PathInfos)
{
Option<JellyfinPathInfo> maybeIncoming = incoming.PathInfos
.Find(pi => pi.Path == existingPathInfo.Path);
foreach (JellyfinPathInfo incomingPathInfo in maybeIncoming)
{
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
} }
} }
foreach (JellyfinLibrary delete in toDelete) foreach (JellyfinPathInfo incomingPathInfo in incoming.PathInfos
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
{ {
dbContext.Entry(delete).State = EntityState.Deleted; existing.PathInfos.Add(incomingPathInfo);
}
}
} }
List<int> ids = await DisableJellyfinLibrarySync(toDelete.Map(l => l.Id).ToList());
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@ -188,26 +216,50 @@ public class MediaSourceRepository : IMediaSourceRepository
public async Task<List<int>> UpdateLibraries( public async Task<List<int>> UpdateLibraries(
int embyMediaSourceId, int embyMediaSourceId,
List<EmbyLibrary> toAdd, List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete) List<EmbyLibrary> toDelete,
List<EmbyLibrary> toUpdate)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (EmbyLibrary add in toAdd) foreach (EmbyLibrary add in toAdd)
{ {
add.MediaSourceId = embyMediaSourceId; add.MediaSourceId = embyMediaSourceId;
dbContext.Entry(add).State = EntityState.Added; dbContext.EmbyLibraries.Add(add);
foreach (LibraryPath path in add.Paths) }
dbContext.EmbyLibraries.RemoveRange(toDelete);
List<int> ids = await DisableEmbyLibrarySync(toDelete.Map(l => l.Id).ToList());
foreach (EmbyLibrary incoming in toUpdate)
{ {
dbContext.Entry(path).State = EntityState.Added; Option<EmbyLibrary> maybeExisting = await dbContext.EmbyLibraries
.Include(l => l.PathInfos)
.SelectOneAsync(l => l.ItemId, l => l.ItemId == incoming.ItemId);
foreach (EmbyLibrary existing in maybeExisting)
{
// remove paths that are not on the incoming version
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
// update all remaining paths
foreach (EmbyPathInfo existingPathInfo in existing.PathInfos)
{
Option<EmbyPathInfo> maybeIncoming = incoming.PathInfos
.Find(pi => pi.Path == existingPathInfo.Path);
foreach (EmbyPathInfo incomingPathInfo in maybeIncoming)
{
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
} }
} }
foreach (EmbyLibrary delete in toDelete) foreach (EmbyPathInfo incomingPathInfo in incoming.PathInfos
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
{ {
dbContext.Entry(delete).State = EntityState.Deleted; existing.PathInfos.Add(incomingPathInfo);
}
}
} }
List<int> ids = await DisableEmbyLibrarySync(toDelete.Map(l => l.Id).ToList());
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@ -259,14 +311,16 @@ public class MediaSourceRepository : IMediaSourceRepository
List<PlexLibrary> allPlexLibraries = await dbContext.PlexLibraries.ToListAsync(); List<PlexLibrary> allPlexLibraries = await dbContext.PlexLibraries.ToListAsync();
dbContext.PlexLibraries.RemoveRange(allPlexLibraries); dbContext.PlexLibraries.RemoveRange(allPlexLibraries);
var libraryIds = allPlexLibraries.Map(l => l.Id).ToList();
List<int> movieIds = await dbContext.PlexMovies.Map(pm => pm.Id).ToListAsync(); List<int> deletedMediaIds = await dbContext.MediaItems
List<int> showIds = await dbContext.PlexShows.Map(ps => ps.Id).ToListAsync(); .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
List<int> episodeIds = await dbContext.PlexEpisodes.Map(pe => pe.Id).ToListAsync(); .Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource) public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
@ -292,83 +346,30 @@ public class MediaSourceRepository : IMediaSourceRepository
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync( List<int> deletedMediaIds = await dbContext.MediaItems
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
new { ids = libraryIds }); .Map(mi => mi.Id)
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds });
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>( List<PlexLibrary> libraries = await dbContext.PlexLibraries
@"SELECT m.Id FROM MediaItem m .Include(l => l.Paths)
INNER JOIN PlexSeason ps ON ps.Id = m.Id .Filter(l => libraryIds.Contains(l.Id))
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId .ToListAsync();
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync( dbContext.PlexLibraries.RemoveRange(libraries);
@"DELETE FROM MediaItem WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> showIds = await dbContext.Connection.QueryAsync<int>( foreach (PlexLibrary library in libraries)
@"SELECT m.Id FROM MediaItem m {
INNER JOIN PlexShow ps ON ps.Id = m.Id library.Id = 0;
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId library.ShouldSyncItems = false;
INNER JOIN Library l ON l.Id = lp.LibraryId library.LastScan = SystemTime.MinValueUtc;
WHERE l.Id IN @ids", }
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync( await dbContext.PlexLibraries.AddRangeAsync(libraries);
@"DELETE FROM MediaItem WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
public async Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) public async Task EnablePlexLibrarySync(IEnumerable<int> libraryIds)
@ -447,6 +448,7 @@ public class MediaSourceRepository : IMediaSourceRepository
return await context.JellyfinMediaSources return await context.JellyfinMediaSources
.Include(p => p.Connections) .Include(p => p.Connections)
.Include(p => p.Libraries) .Include(p => p.Libraries)
.ThenInclude(l => (l as JellyfinLibrary).PathInfos)
.Include(p => p.PathReplacements) .Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579 .OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id) .SingleOrDefaultAsync(p => p.Id == id)
@ -473,83 +475,31 @@ public class MediaSourceRepository : IMediaSourceRepository
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync( List<int> deletedMediaIds = await dbContext.MediaItems
"UPDATE JellyfinLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
new { ids = libraryIds }); .Map(mi => mi.Id)
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds });
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>( List<JellyfinLibrary> libraries = await dbContext.JellyfinLibraries
@"SELECT m.Id FROM MediaItem m .Include(l => l.Paths)
INNER JOIN JellyfinSeason js ON js.Id = m.Id .Include(l => l.PathInfos)
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId .Filter(l => libraryIds.Contains(l.Id))
INNER JOIN Library l ON l.Id = lp.LibraryId .ToListAsync();
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync( dbContext.JellyfinLibraries.RemoveRange(libraries);
@"DELETE FROM MediaItem WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> showIds = await dbContext.Connection.QueryAsync<int>( foreach (JellyfinLibrary library in libraries)
@"SELECT m.Id FROM MediaItem m {
INNER JOIN JellyfinShow ps ON ps.Id = m.Id library.Id = 0;
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId library.ShouldSyncItems = false;
INNER JOIN Library l ON l.Id = lp.LibraryId library.LastScan = SystemTime.MinValueUtc;
WHERE l.Id IN @ids", }
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync( await dbContext.JellyfinLibraries.AddRangeAsync(libraries);
@"DELETE FROM MediaItem WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
public async Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId) public async Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@ -557,6 +507,8 @@ public class MediaSourceRepository : IMediaSourceRepository
await using TvContext context = await _dbContextFactory.CreateDbContextAsync(); await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
return await context.JellyfinLibraries return await context.JellyfinLibraries
.Include(l => l.Paths) .Include(l => l.Paths)
.Include(l => l.PathInfos)
.Include(l => l.MediaSource)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579 .OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId) .SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
.Map(Optional); .Map(Optional);
@ -654,24 +606,14 @@ public class MediaSourceRepository : IMediaSourceRepository
var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList(); var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList();
dbContext.JellyfinLibraries.RemoveRange(allJellyfinLibraries); dbContext.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
List<int> movieIds = await dbContext.JellyfinMovies List<int> deletedMediaIds = await dbContext.MediaItems
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(pm => pm.Id) .Map(mi => mi.Id)
.ToListAsync();
List<int> showIds = await dbContext.JellyfinShows
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await dbContext.JellyfinEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync(); .ToListAsync();
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem) public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
@ -742,10 +684,9 @@ public class MediaSourceRepository : IMediaSourceRepository
return await context.EmbyMediaSources return await context.EmbyMediaSources
.Include(p => p.Connections) .Include(p => p.Connections)
.Include(p => p.Libraries) .Include(p => p.Libraries)
.ThenInclude(l => (l as EmbyLibrary).PathInfos)
.Include(p => p.PathReplacements) .Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579 .SelectOneAsync(s => s.Id, s => s.Id == id);
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
} }
public async Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId) public async Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId)
@ -771,9 +712,9 @@ public class MediaSourceRepository : IMediaSourceRepository
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EmbyLibraries return await dbContext.EmbyLibraries
.Include(l => l.Paths) .Include(l => l.Paths)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579 .Include(l => l.PathInfos)
.SingleOrDefaultAsync(l => l.Id == embyLibraryId) .Include(l => l.MediaSource)
.Map(Optional); .SelectOneAsync(l => l.Id, l => l.Id == embyLibraryId);
} }
public async Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId) public async Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId)
@ -858,24 +799,14 @@ public class MediaSourceRepository : IMediaSourceRepository
var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList(); var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList();
dbContext.EmbyLibraries.RemoveRange(allEmbyLibraries); dbContext.EmbyLibraries.RemoveRange(allEmbyLibraries);
List<int> movieIds = await dbContext.EmbyMovies List<int> deletedMediaIds = await dbContext.MediaItems
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId)) .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(pm => pm.Id) .Map(mi => mi.Id)
.ToListAsync();
List<int> showIds = await dbContext.EmbyShows
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await dbContext.EmbyEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync(); .ToListAsync();
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
public async Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds) public async Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds)
@ -890,122 +821,30 @@ public class MediaSourceRepository : IMediaSourceRepository
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync( List<int> deletedMediaIds = await dbContext.MediaItems
"UPDATE EmbyLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", .Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
new { ids = libraryIds }); .Map(mi => mi.Id)
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds });
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyMovie WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Movie WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyEpisode WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Episode WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbySeason WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Season WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> showIds = await dbContext.Connection.QueryAsync<int>( List<EmbyLibrary> libraries = await dbContext.EmbyLibraries
@"SELECT m.Id FROM MediaItem m .Include(l => l.Paths)
INNER JOIN EmbyShow ps ON ps.Id = m.Id .Include(l => l.PathInfos)
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids", .Filter(l => libraryIds.Contains(l.Id))
new { ids = libraryIds }).Map(result => result.ToList()); .ToListAsync();
await dbContext.Connection.ExecuteAsync( dbContext.EmbyLibraries.RemoveRange(libraries);
@"DELETE FROM EmbyShow WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync( foreach (EmbyLibrary library in libraries)
@"DELETE FROM Show WHERE Id IN {
(SELECT m.Id FROM MediaItem m library.Id = 0;
INNER JOIN EmbyShow ps ON ps.Id = m.Id library.ShouldSyncItems = false;
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)", library.LastScan = SystemTime.MinValueUtc;
new { ids = libraryIds }); }
await dbContext.Connection.ExecuteAsync( await dbContext.EmbyLibraries.AddRangeAsync(libraries);
@"DELETE FROM MediaItem WHERE Id IN await dbContext.SaveChangesAsync();
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList(); return deletedMediaIds;
} }
} }

4
ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs

@ -147,6 +147,7 @@ public class PlexMovieRepository : IPlexMovieRepository
try try
{ {
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -154,6 +155,9 @@ public class PlexMovieRepository : IPlexMovieRepository
await dbContext.PlexMovies.AddAsync(item); await dbContext.PlexMovies.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true }; return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };

12
ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs

@ -301,6 +301,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
try try
{ {
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -308,6 +309,9 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
await dbContext.PlexShows.AddAsync(item); await dbContext.PlexShows.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true }; return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
@ -326,6 +330,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
try try
{ {
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -333,6 +338,9 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
await dbContext.PlexSeasons.AddAsync(item); await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexSeason>(item) { IsAdded = true }; return new MediaItemScanResult<PlexSeason>(item) { IsAdded = true };
@ -356,6 +364,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
} }
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -372,6 +381,9 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
await dbContext.PlexEpisodes.AddAsync(item); await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();

62
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Emby;
public class EmbyApiClient : IEmbyApiClient public class EmbyApiClient : IEmbyApiClient
{ {
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<EmbyApiClient> _logger; private readonly ILogger<EmbyApiClient> _logger;
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
@ -19,10 +20,12 @@ public class EmbyApiClient : IEmbyApiClient
public EmbyApiClient( public EmbyApiClient(
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
IMemoryCache memoryCache, IMemoryCache memoryCache,
IEmbyPathReplacementService embyPathReplacementService,
ILogger<EmbyApiClient> logger) ILogger<EmbyApiClient> logger)
{ {
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_memoryCache = memoryCache; _memoryCache = memoryCache;
_embyPathReplacementService = embyPathReplacementService;
_logger = logger; _logger = logger;
} }
@ -71,14 +74,14 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems( public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address, string address,
string apiKey, string apiKey,
string libraryId) EmbyLibrary library)
{ {
try try
{ {
IEmbyApi service = RestService.For<IEmbyApi>(address); IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, libraryId); EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, library.ItemId);
return items.Items return items.Items
.Map(ProjectToMovie) .Map(i => ProjectToMovie(library, i))
.Somes() .Somes()
.ToList(); .ToList();
} }
@ -134,6 +137,7 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems( public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
string address, string address,
string apiKey, string apiKey,
EmbyLibrary library,
string seasonId) string seasonId)
{ {
try try
@ -141,7 +145,7 @@ public class EmbyApiClient : IEmbyApiClient
IEmbyApi service = RestService.For<IEmbyApi>(address); IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId); EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId);
return items.Items return items.Items
.Map(ProjectToEpisode) .Map(i => ProjectToEpisode(library, i))
.Somes() .Somes()
.ToList(); .ToList();
} }
@ -245,7 +249,13 @@ public class EmbyApiClient : IEmbyApiClient
Name = response.Name, Name = response.Name,
MediaKind = LibraryMediaKind.Shows, MediaKind = LibraryMediaKind.Shows,
ShouldSyncItems = false, ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } } Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } },
PathInfos = response.LibraryOptions.PathInfos.Map(
pi => new EmbyPathInfo
{
Path = pi.Path,
NetworkPath = pi.NetworkPath
}).ToList()
}, },
"movies" => new EmbyLibrary "movies" => new EmbyLibrary
{ {
@ -253,7 +263,13 @@ public class EmbyApiClient : IEmbyApiClient
Name = response.Name, Name = response.Name,
MediaKind = LibraryMediaKind.Movies, MediaKind = LibraryMediaKind.Movies,
ShouldSyncItems = false, ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } } Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } },
PathInfos = response.LibraryOptions.PathInfos.Map(
pi => new EmbyPathInfo
{
Path = pi.Path,
NetworkPath = pi.NetworkPath
}).ToList()
}, },
// TODO: ??? for music libraries // TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId), "boxsets" => CacheCollectionLibraryId(response.ItemId),
@ -266,7 +282,7 @@ public class EmbyApiClient : IEmbyApiClient
return None; return None;
} }
private Option<EmbyMovie> ProjectToMovie(EmbyLibraryItemResponse item) private Option<EmbyMovie> ProjectToMovie(EmbyLibrary library, EmbyLibraryItemResponse item)
{ {
try try
{ {
@ -275,6 +291,19 @@ public class EmbyApiClient : IEmbyApiClient
return None; return None;
} }
string path = item.Path ?? string.Empty;
foreach (EmbyPathInfo pathInfo in library.PathInfos)
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _embyPathReplacementService.ReplaceNetworkPath(
(EmbyMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
@ -284,7 +313,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
new() new()
{ {
Path = item.Path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()
@ -568,7 +597,7 @@ public class EmbyApiClient : IEmbyApiClient
} }
} }
private Option<EmbyEpisode> ProjectToEpisode(EmbyLibraryItemResponse item) private Option<EmbyEpisode> ProjectToEpisode(EmbyLibrary library, EmbyLibraryItemResponse item)
{ {
try try
{ {
@ -577,6 +606,19 @@ public class EmbyApiClient : IEmbyApiClient
return None; return None;
} }
string path = item.Path ?? string.Empty;
foreach (EmbyPathInfo pathInfo in library.PathInfos)
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _embyPathReplacementService.ReplaceNetworkPath(
(EmbyMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
@ -586,7 +628,7 @@ public class EmbyApiClient : IEmbyApiClient
{ {
new() new()
{ {
Path = item.Path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()

6
ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryOptionsResponse.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Infrastructure.Emby.Models;
public class EmbyLibraryOptionsResponse
{
public List<EmbyPathInfosResponse> PathInfos { get; set; }
}

1
ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryResponse.cs

@ -5,4 +5,5 @@ public class EmbyLibraryResponse
public string Name { get; set; } public string Name { get; set; }
public string CollectionType { get; set; } public string CollectionType { get; set; }
public string ItemId { get; set; } public string ItemId { get; set; }
public EmbyLibraryOptionsResponse LibraryOptions { get; set; }
} }

7
ErsatzTV.Infrastructure/Emby/Models/EmbyPathInfosResponse.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Emby.Models;
public class EmbyPathInfosResponse
{
public string Path { get; set; }
public string NetworkPath { get; set; }
}

4
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -36,7 +36,9 @@ public interface IJellyfinApi
[Query] [Query]
string includeItemTypes = "Movie", string includeItemTypes = "Movie",
[Query] [Query]
bool recursive = true); bool recursive = true,
[Query]
string filters = "IsNotFolder");
[Get("/Items")] [Get("/Items")]
public Task<JellyfinLibraryItemsResponse> GetShowLibraryItems( public Task<JellyfinLibraryItemsResponse> GetShowLibraryItems(

68
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -13,16 +13,19 @@ namespace ErsatzTV.Infrastructure.Jellyfin;
public class JellyfinApiClient : IJellyfinApiClient public class JellyfinApiClient : IJellyfinApiClient
{ {
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<JellyfinApiClient> _logger; private readonly ILogger<JellyfinApiClient> _logger;
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
public JellyfinApiClient( public JellyfinApiClient(
IMemoryCache memoryCache, IMemoryCache memoryCache,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
IJellyfinPathReplacementService jellyfinPathReplacementService,
ILogger<JellyfinApiClient> logger) ILogger<JellyfinApiClient> logger)
{ {
_memoryCache = memoryCache; _memoryCache = memoryCache;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_logger = logger; _logger = logger;
} }
@ -91,17 +94,16 @@ public class JellyfinApiClient : IJellyfinApiClient
public async Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems( public async Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address, string address,
string apiKey, string apiKey,
int mediaSourceId, JellyfinLibrary library)
string libraryId)
{ {
try try
{ {
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
{ {
IJellyfinApi service = RestService.For<IJellyfinApi>(address); IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, libraryId); JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, library.ItemId);
return items.Items return items.Items
.Map(ProjectToMovie) .Map(i => ProjectToMovie(library, i))
.Somes() .Somes()
.ToList(); .ToList();
} }
@ -172,17 +174,17 @@ public class JellyfinApiClient : IJellyfinApiClient
public async Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems( public async Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
string address, string address,
string apiKey, string apiKey,
int mediaSourceId, JellyfinLibrary library,
string seasonId) string seasonId)
{ {
try try
{ {
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId)) if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
{ {
IJellyfinApi service = RestService.For<IJellyfinApi>(address); IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId); JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId);
return items.Items return items.Items
.Map(ProjectToEpisode) .Map(i => ProjectToEpisode(library, i))
.Somes() .Somes()
.ToList(); .ToList();
} }
@ -300,7 +302,13 @@ public class JellyfinApiClient : IJellyfinApiClient
Name = response.Name, Name = response.Name,
MediaKind = LibraryMediaKind.Shows, MediaKind = LibraryMediaKind.Shows,
ShouldSyncItems = false, ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } } Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = response.LibraryOptions.PathInfos.Map(
pi => new JellyfinPathInfo
{
Path = pi.Path,
NetworkPath = pi.NetworkPath
}).ToList()
}, },
"movies" => new JellyfinLibrary "movies" => new JellyfinLibrary
{ {
@ -308,7 +316,13 @@ public class JellyfinApiClient : IJellyfinApiClient
Name = response.Name, Name = response.Name,
MediaKind = LibraryMediaKind.Movies, MediaKind = LibraryMediaKind.Movies,
ShouldSyncItems = false, ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } } Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
PathInfos = response.LibraryOptions.PathInfos.Map(
pi => new JellyfinPathInfo
{
Path = pi.Path,
NetworkPath = pi.NetworkPath
}).ToList()
}, },
// TODO: ??? for music libraries // TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId), "boxsets" => CacheCollectionLibraryId(response.ItemId),
@ -321,7 +335,7 @@ public class JellyfinApiClient : IJellyfinApiClient
return None; return None;
} }
private Option<JellyfinMovie> ProjectToMovie(JellyfinLibraryItemResponse item) private Option<JellyfinMovie> ProjectToMovie(JellyfinLibrary library, JellyfinLibraryItemResponse item)
{ {
try try
{ {
@ -336,6 +350,19 @@ public class JellyfinApiClient : IJellyfinApiClient
return None; return None;
} }
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in library.PathInfos)
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
@ -345,7 +372,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
new() new()
{ {
Path = item.Path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()
@ -647,7 +674,7 @@ public class JellyfinApiClient : IJellyfinApiClient
} }
} }
private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibraryItemResponse item) private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibrary library, JellyfinLibraryItemResponse item)
{ {
try try
{ {
@ -662,6 +689,19 @@ public class JellyfinApiClient : IJellyfinApiClient
return None; return None;
} }
string path = item.Path ?? string.Empty;
foreach (JellyfinPathInfo pathInfo in library.PathInfos)
{
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
{
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
(JellyfinMediaSource)library.MediaSource,
path,
pathInfo.NetworkPath,
pathInfo.Path);
}
}
var version = new MediaVersion var version = new MediaVersion
{ {
Name = "Main", Name = "Main",
@ -671,7 +711,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{ {
new() new()
{ {
Path = item.Path Path = path
} }
}, },
Streams = new List<MediaStream>() Streams = new List<MediaStream>()

6
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryOptionsResponse.cs

@ -0,0 +1,6 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
public class JellyfinLibraryOptionsResponse
{
public List<JellyfinPathInfosResponse> PathInfos { get; set; }
}

1
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryResponse.cs

@ -5,4 +5,5 @@ public class JellyfinLibraryResponse
public string Name { get; set; } public string Name { get; set; }
public string CollectionType { get; set; } public string CollectionType { get; set; }
public string ItemId { get; set; } public string ItemId { get; set; }
public JellyfinLibraryOptionsResponse LibraryOptions { get; set; }
} }

7
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPathInfosResponse.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models;
public class JellyfinPathInfosResponse
{
public string Path { get; set; }
public string NetworkPath { get; set; }
}

4175
ErsatzTV.Infrastructure/Migrations/20220429003054_Add_JellyfinLibraryPathInfos.Designer.cs generated

File diff suppressed because it is too large Load Diff

44
ErsatzTV.Infrastructure/Migrations/20220429003054_Add_JellyfinLibraryPathInfos.cs

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_JellyfinLibraryPathInfos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JellyfinPathInfo",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Path = table.Column<string>(type: "TEXT", nullable: true),
NetworkPath = table.Column<string>(type: "TEXT", nullable: true),
JellyfinLibraryId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_JellyfinPathInfo", x => x.Id);
table.ForeignKey(
name: "FK_JellyfinPathInfo_JellyfinLibrary_JellyfinLibraryId",
column: x => x.JellyfinLibraryId,
principalTable: "JellyfinLibrary",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_JellyfinPathInfo_JellyfinLibraryId",
table: "JellyfinPathInfo",
column: "JellyfinLibraryId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JellyfinPathInfo");
}
}
}

4210
ErsatzTV.Infrastructure/Migrations/20220429153234_Add_EmbyLibraryPathInfos.Designer.cs generated

File diff suppressed because it is too large Load Diff

44
ErsatzTV.Infrastructure/Migrations/20220429153234_Add_EmbyLibraryPathInfos.cs

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EmbyLibraryPathInfos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmbyPathInfo",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Path = table.Column<string>(type: "TEXT", nullable: true),
NetworkPath = table.Column<string>(type: "TEXT", nullable: true),
EmbyLibraryId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmbyPathInfo", x => x.Id);
table.ForeignKey(
name: "FK_EmbyPathInfo_EmbyLibrary_EmbyLibraryId",
column: x => x.EmbyLibraryId,
principalTable: "EmbyLibrary",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EmbyPathInfo_EmbyLibraryId",
table: "EmbyPathInfo",
column: "EmbyLibraryId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmbyPathInfo");
}
}
}

4210
ErsatzTV.Infrastructure/Migrations/20220429163623_Rescan_EmbyJellyfinLibrariesPathInfos.Designer.cs generated

File diff suppressed because it is too large Load Diff

30
ErsatzTV.Infrastructure/Migrations/20220429163623_Rescan_EmbyJellyfinLibrariesPathInfos.cs

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Rescan_EmbyJellyfinLibrariesPathInfos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Id IN (SELECT Id FROM EmbyLibrary)");
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Id IN (SELECT Id FROM JellyfinLibrary)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

70
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -2132,6 +2132,50 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Writer", (string)null); b.ToTable("Writer", (string)null);
}); });
modelBuilder.Entity("ErsatzTV.Core.Emby.EmbyPathInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EmbyLibraryId")
.HasColumnType("INTEGER");
b.Property<string>("NetworkPath")
.HasColumnType("TEXT");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EmbyLibraryId");
b.ToTable("EmbyPathInfo");
});
modelBuilder.Entity("ErsatzTV.Core.Jellyfin.JellyfinPathInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("JellyfinLibraryId")
.HasColumnType("INTEGER");
b.Property<string>("NetworkPath")
.HasColumnType("TEXT");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JellyfinLibraryId");
b.ToTable("JellyfinPathInfo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b =>
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3504,6 +3548,22 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity("ErsatzTV.Core.Emby.EmbyPathInfo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.EmbyLibrary", null)
.WithMany("PathInfos")
.HasForeignKey("EmbyLibraryId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Jellyfin.JellyfinPathInfo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.JellyfinLibrary", null)
.WithMany("PathInfos")
.HasForeignKey("JellyfinLibraryId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b =>
{ {
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -4063,6 +4123,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideos"); b.Navigation("MusicVideos");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b =>
{
b.Navigation("PathInfos");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b => modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b =>
{ {
b.Navigation("Connections"); b.Navigation("Connections");
@ -4077,6 +4142,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaVersions"); b.Navigation("MediaVersions");
}); });
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinLibrary", b =>
{
b.Navigation("PathInfos");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b => modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b =>
{ {
b.Navigation("Connections"); b.Navigation("Connections");

5
ErsatzTV/Pages/EmbyLibrariesEditor.razor

@ -45,9 +45,10 @@
ShouldSyncItems = library.ShouldSyncItems ShouldSyncItems = library.ShouldSyncItems
}; };
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(int libraryId) private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters)
{ {
await _channel.WriteAsync(new SynchronizeEmbyLibraryByIdIfNeeded(libraryId), _cts.Token); await _channel.WriteAsync(new SynchronizeEmbyLibraries(parameters.MediaSourceId), _cts.Token);
await _channel.WriteAsync(new SynchronizeEmbyLibraryByIdIfNeeded(parameters.LibraryId), _cts.Token);
return Unit.Default; return Unit.Default;
} }

5
ErsatzTV/Pages/JellyfinLibrariesEditor.razor

@ -45,9 +45,10 @@
ShouldSyncItems = library.ShouldSyncItems ShouldSyncItems = library.ShouldSyncItems
}; };
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(int libraryId) private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters)
{ {
await _channel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId), _cts.Token); await _channel.WriteAsync(new SynchronizeJellyfinLibraries(parameters.MediaSourceId), _cts.Token);
await _channel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(parameters.LibraryId), _cts.Token);
return Unit.Default; return Unit.Default;
} }

2
ErsatzTV/Pages/Libraries.razor

@ -119,9 +119,11 @@
await _plexWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token); await _plexWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token);
break; break;
case JellyfinLibraryViewModel: case JellyfinLibraryViewModel:
await _jellyfinWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token);
await _jellyfinWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id), _cts.Token); await _jellyfinWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id), _cts.Token);
break; break;
case EmbyLibraryViewModel: case EmbyLibraryViewModel:
await _embyWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(library.MediaSourceId), _cts.Token);
await _embyWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id), _cts.Token); await _embyWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id), _cts.Token);
break; break;
} }

4
ErsatzTV/Pages/PlexLibrariesEditor.razor

@ -45,9 +45,9 @@
ShouldSyncItems = library.ShouldSyncItems ShouldSyncItems = library.ShouldSyncItems
}; };
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(int libraryId) private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters)
{ {
await _channel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(libraryId), _cts.Token); await _channel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(parameters.LibraryId), _cts.Token);
return Unit.Default; return Unit.Default;
} }

33
ErsatzTV/Services/EmbyService.cs

@ -50,9 +50,6 @@ public class EmbyService : BackgroundService
case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources: case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources:
requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken); requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken);
break; break;
// case SynchronizeEmbyAdminUserId synchronizeEmbyAdminUserId:
// requestTask = SynchronizeAdminUserId(synchronizeEmbyAdminUserId, cancellationToken);
// break;
case SynchronizeEmbyLibraries synchronizeEmbyLibraries: case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken); requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken);
break; break;
@ -93,9 +90,7 @@ public class EmbyService : BackgroundService
} }
} }
private async Task SynchronizeSources( private async Task SynchronizeSources(SynchronizeEmbyMediaSources request, CancellationToken cancellationToken)
SynchronizeEmbyMediaSources request,
CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
@ -117,9 +112,7 @@ public class EmbyService : BackgroundService
}); });
} }
private async Task SynchronizeLibraries( private async Task SynchronizeLibraries(SynchronizeEmbyLibraries request, CancellationToken cancellationToken)
SynchronizeEmbyLibraries request,
CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
@ -135,27 +128,7 @@ public class EmbyService : BackgroundService
error.Value)); error.Value));
} }
// private async Task SynchronizeAdminUserId( private async Task SynchronizeEmbyLibrary(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
// SynchronizeEmbyAdminUserId request,
// CancellationToken cancellationToken)
// {
// using IServiceScope scope = _serviceScopeFactory.CreateScope();
// IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
//
// Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
// result.BiIter(
// _ => _logger.LogInformation(
// "Successfully synchronized Emby admin user id for source {MediaSourceId}",
// request.EmbyMediaSourceId),
// error => _logger.LogWarning(
// "Unable to synchronize Emby admin user id for source {MediaSourceId}: {Error}",
// request.EmbyMediaSourceId,
// error.Value));
// }
private async Task SynchronizeEmbyLibrary(
ISynchronizeEmbyLibraryById request,
CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

4
ErsatzTV/Services/JellyfinService.cs

@ -119,9 +119,7 @@ public class JellyfinService : BackgroundService
}); });
} }
private async Task SynchronizeLibraries( private async Task SynchronizeLibraries(SynchronizeJellyfinLibraries request, CancellationToken cancellationToken)
SynchronizeJellyfinLibraries request,
CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

55
ErsatzTV/Services/SchedulerService.cs

@ -1,6 +1,8 @@
using System.Threading.Channels; using System.Threading.Channels;
using Bugsnag; using Bugsnag;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaSources; using ErsatzTV.Application.MediaSources;
@ -17,7 +19,9 @@ namespace ErsatzTV.Services;
public class SchedulerService : BackgroundService public class SchedulerService : BackgroundService
{ {
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _embyWorkerChannel;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _jellyfinWorkerChannel;
private readonly ILogger<SchedulerService> _logger; private readonly ILogger<SchedulerService> _logger;
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _plexWorkerChannel; private readonly ChannelWriter<IPlexBackgroundServiceRequest> _plexWorkerChannel;
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
@ -27,12 +31,16 @@ public class SchedulerService : BackgroundService
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ChannelWriter<IPlexBackgroundServiceRequest> plexWorkerChannel, ChannelWriter<IPlexBackgroundServiceRequest> plexWorkerChannel,
ChannelWriter<IJellyfinBackgroundServiceRequest> jellyfinWorkerChannel,
ChannelWriter<IEmbyBackgroundServiceRequest> embyWorkerChannel,
IEntityLocker entityLocker, IEntityLocker entityLocker,
ILogger<SchedulerService> logger) ILogger<SchedulerService> logger)
{ {
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_workerChannel = workerChannel; _workerChannel = workerChannel;
_plexWorkerChannel = plexWorkerChannel; _plexWorkerChannel = plexWorkerChannel;
_jellyfinWorkerChannel = jellyfinWorkerChannel;
_embyWorkerChannel = embyWorkerChannel;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_logger = logger; _logger = logger;
} }
@ -89,6 +97,8 @@ public class SchedulerService : BackgroundService
await BuildPlayouts(cancellationToken); await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken); await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken); await ScanPlexMediaSources(cancellationToken);
await ScanJellyfinMediaSources(cancellationToken);
await ScanEmbyMediaSources(cancellationToken);
await MatchTraktLists(cancellationToken); await MatchTraktLists(cancellationToken);
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@ -177,12 +187,7 @@ public class SchedulerService : BackgroundService
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>(); TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<int> localLibraryIds = await dbContext.LocalMediaSources foreach (int libraryId in dbContext.LocalMediaSources.SelectMany(ms => ms.Libraries).Map(l => l.Id))
.SelectMany(ms => ms.Libraries)
.Map(l => l.Id)
.ToListAsync(cancellationToken);
foreach (int libraryId in localLibraryIds)
{ {
if (_entityLocker.LockLibrary(libraryId)) if (_entityLocker.LockLibrary(libraryId))
{ {
@ -198,11 +203,7 @@ public class SchedulerService : BackgroundService
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>(); TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<PlexLibrary> plexLibraries = await dbContext.PlexLibraries foreach (PlexLibrary library in dbContext.PlexLibraries.Filter(l => l.ShouldSyncItems))
.Filter(l => l.ShouldSyncItems)
.ToListAsync(cancellationToken);
foreach (PlexLibrary library in plexLibraries)
{ {
if (_entityLocker.LockLibrary(library.Id)) if (_entityLocker.LockLibrary(library.Id))
{ {
@ -213,6 +214,38 @@ public class SchedulerService : BackgroundService
} }
} }
private async Task ScanJellyfinMediaSources(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
foreach (JellyfinLibrary library in dbContext.JellyfinLibraries.Filter(l => l.ShouldSyncItems))
{
if (_entityLocker.LockLibrary(library.Id))
{
await _jellyfinWorkerChannel.WriteAsync(
new SynchronizeJellyfinLibraryByIdIfNeeded(library.Id),
cancellationToken);
}
}
}
private async Task ScanEmbyMediaSources(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
foreach (EmbyLibrary library in dbContext.EmbyLibraries.Filter(l => l.ShouldSyncItems))
{
if (_entityLocker.LockLibrary(library.Id))
{
await _embyWorkerChannel.WriteAsync(
new SynchronizeEmbyLibraryByIdIfNeeded(library.Id),
cancellationToken);
}
}
}
private async Task MatchTraktLists(CancellationToken cancellationToken) private async Task MatchTraktLists(CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();

2
ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor

@ -70,7 +70,7 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
_newLibrary = new LocalLibraryViewModel(-1, "(New Library)", MediaKind); _newLibrary = new LocalLibraryViewModel(-1, "(New Library)", MediaKind, -1);
_libraries = await _mediator.Send(new GetAllLocalLibraries(), _cts.Token) _libraries = await _mediator.Send(new GetAllLocalLibraries(), _cts.Token)
.Map(list => list.Filter(ll => ll.MediaKind == MediaKind && ll.Id != SourceLibraryId)) .Map(list => list.Filter(ll => ll.MediaKind == MediaKind && ll.Id != SourceLibraryId))

10
ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor

@ -61,11 +61,13 @@
public Func<List<RemoteMediaSourceLibraryEditViewModel>, IRequest<Either<BaseError, Unit>>> GetUpdateLibraryRequest { get; set; } public Func<List<RemoteMediaSourceLibraryEditViewModel>, IRequest<Either<BaseError, Unit>>> GetUpdateLibraryRequest { get; set; }
[Parameter] [Parameter]
public Func<int, Task<Unit>> SynchronizeLibraryByIdIfNeeded { get; set; } public Func<SynchronizeParameters, Task<Unit>> SynchronizeLibraryByIdIfNeeded { get; set; }
private RemoteMediaSourceViewModel _source; private RemoteMediaSourceViewModel _source;
private List<RemoteMediaSourceLibraryEditViewModel> _libraries; private List<RemoteMediaSourceLibraryEditViewModel> _libraries;
public record SynchronizeParameters(int LibraryId, int MediaSourceId);
public void Dispose() public void Dispose()
{ {
_cts.Cancel(); _cts.Cancel();
@ -104,11 +106,11 @@
}, },
async () => async () =>
{ {
foreach (int id in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id)) foreach (int libraryId in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id))
{ {
if (_locker.LockLibrary(id)) if (_locker.LockLibrary(libraryId))
{ {
await SynchronizeLibraryByIdIfNeeded(id); await SynchronizeLibraryByIdIfNeeded(new SynchronizeParameters(libraryId, Id));
} }
} }

Loading…
Cancel
Save