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/). @@ -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
- 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 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
- 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
- Jellyfin and Emby movie and show library scanners now support the `unavailable` media state
- 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
- 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 `season_number` and `episode_number` to search index for all episodes
- Add `season_number` to search index for seasons

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

@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging; @@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
public class
SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
{
private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbySecretStore _embySecretStore;
@ -72,32 +71,33 @@ public class @@ -72,32 +71,33 @@ public class
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
await maybeLibraries.Match(
async libraries =>
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_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>()
.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 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(
connectionParameters.EmbyMediaSource.Id,
toAdd,
toRemove);
toRemove,
toUpdate);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_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;
}

9
ErsatzTV.Application/Emby/EmbyLibraryViewModel.cs

@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain; @@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Emby;
public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
: LibraryViewModel("Emby", Id, Name, MediaKind);
public record EmbyLibraryViewModel(
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 @@ -11,7 +11,7 @@ internal static class Mapper
embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
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) =>
new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath);

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

@ -9,9 +9,7 @@ using Microsoft.Extensions.Logging; @@ -9,9 +9,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries,
Either<BaseError, Unit>>
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries, Either<BaseError, Unit>>
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
@ -74,32 +72,34 @@ public class @@ -74,32 +72,34 @@ public class
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
await maybeLibraries.Match(
async libraries =>
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_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();
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 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(
connectionParameters.JellyfinMediaSource.Id,
toAdd,
toRemove);
toRemove,
toUpdate);
if (ids.Any())
{
await _searchIndex.RemoveItems(ids);
_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;
}

9
ErsatzTV.Application/Jellyfin/JellyfinLibraryViewModel.cs

@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain; @@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Jellyfin;
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
: LibraryViewModel("Jellyfin", Id, Name, MediaKind);
public record JellyfinLibraryViewModel(
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 @@ -11,7 +11,7 @@ internal static class Mapper
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
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) =>
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);

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

@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase, @@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
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(

2
ErsatzTV.Application/Libraries/LibraryViewModel.cs

@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
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 @@ @@ -2,5 +2,5 @@
namespace ErsatzTV.Application.Libraries;
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Local", Id, Name, MediaKind);
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
: LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId);

13
ErsatzTV.Application/Libraries/Mapper.cs

@ -10,14 +10,19 @@ internal static class Mapper @@ -10,14 +10,19 @@ internal static class Mapper
library switch
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind, p.MediaSourceId),
JellyfinLibrary j => new JellyfinLibraryViewModel(
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))
};
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) =>
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);

4
ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs

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

2
ErsatzTV.Application/MediaCards/Mapper.cs

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

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

@ -71,8 +71,15 @@ public class @@ -71,8 +71,15 @@ public class
connectionParameters.ActiveConnection,
connectionParameters.PlexServerAuthToken);
await maybeLibraries.Match(
async libraries =>
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_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 toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
@ -86,16 +93,7 @@ public class @@ -86,16 +93,7 @@ public class
await _searchIndex.RemoveItems(ids);
_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;
}

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

@ -77,6 +77,30 @@ public class EmbyPathReplacementServiceTests @@ -77,6 +77,30 @@ public class EmbyPathReplacementServiceTests
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]
public async Task EmbyWindows_To_EtvLinux_UncPath()
{

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

@ -60,6 +60,22 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -60,6 +60,22 @@ public class FakeTelevisionRepository : ITelevisionRepository
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(
PlexLibrary library,
PlexShow item) =>
@ -73,14 +89,6 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -73,14 +89,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
PlexEpisode item) =>
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) =>
throw new NotSupportedException();
@ -90,13 +98,6 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -90,13 +98,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
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(PlexSeason season, string etag) => throw new NotSupportedException();

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

@ -77,6 +77,30 @@ public class JellyfinPathReplacementServiceTests @@ -77,6 +77,30 @@ public class JellyfinPathReplacementServiceTests
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]
public async Task JellyfinWindows_To_EtvLinux_UncPath()
{

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

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

3
ErsatzTV.Core/Domain/ChannelSubtitleMode.cs

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

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

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

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

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
namespace ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Domain;
public class JellyfinLibrary : Library
{
public string ItemId { 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 : @@ -85,7 +85,7 @@ public class EmbyMovieLibraryScanner :
_embyApiClient.GetMovieLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
library);
protected override Task<Option<MovieMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,

8
ErsatzTV.Core/Emby/EmbyPathInfo.cs

@ -0,0 +1,8 @@ @@ -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 @@ -31,7 +31,39 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
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
.SingleOrDefault(
@ -50,11 +82,11 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -50,11 +82,11 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
foreach (EmbyPathReplacement replacement in maybeReplacement)
{
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(@"\", @"/");
}
else if (!IsWindows(replacement.EmbyMediaSource, path) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
else if (!IsWindows(replacement.EmbyMediaSource, path) && isTargetPlatformWindows)
{
finalPath = finalPath.Replace(@"/", @"\");
}
@ -73,10 +105,4 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -73,10 +105,4 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
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< @@ -108,6 +108,7 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(

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

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

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

@ -6,4 +6,5 @@ public interface IEmbyPathReplacementService @@ -6,4 +6,5 @@ public interface IEmbyPathReplacementService
{
Task<string> GetReplacementEmbyPath(int libraryPathId, 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 @@ -12,8 +12,7 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId);
JellyfinLibrary library);
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
string address,
@ -30,7 +29,7 @@ public interface IJellyfinApiClient @@ -30,7 +29,7 @@ public interface IJellyfinApiClient
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
string address,
string apiKey,
int mediaSourceId,
JellyfinLibrary library,
string seasonId);
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(

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

@ -6,4 +6,5 @@ public interface IJellyfinPathReplacementService @@ -6,4 +6,5 @@ public interface IJellyfinPathReplacementService
{
Task<string> GetReplacementJellyfinPath(int libraryPathId, 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 @@ -26,12 +26,14 @@ public interface IMediaSourceRepository
Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete);
List<JellyfinLibrary> toDelete,
List<JellyfinLibrary> toUpdate);
Task<List<int>> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete);
List<EmbyLibrary> toDelete,
List<EmbyLibrary> toUpdate);
Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,

3
ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs

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

8
ErsatzTV.Core/Jellyfin/JellyfinPathInfo.cs

@ -0,0 +1,8 @@ @@ -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 @@ -34,7 +34,36 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
public string GetReplacementJellyfinPath(
List<JellyfinPathReplacement> pathReplacements,
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
.SingleOrDefault(
@ -55,13 +84,11 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService @@ -55,13 +84,11 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
foreach (JellyfinPathReplacement replacement in maybeReplacement)
{
string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath);
if (IsWindows(replacement.JellyfinMediaSource, path) &&
!_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
if (IsWindows(replacement.JellyfinMediaSource, path) && !isTargetPlatformWindows)
{
finalPath = finalPath.Replace(@"\", @"/");
}
else if (!IsWindows(replacement.JellyfinMediaSource, path) &&
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
else if (!IsWindows(replacement.JellyfinMediaSource, path) && isTargetPlatformWindows)
{
finalPath = finalPath.Replace(@"/", @"\");
}
@ -80,10 +107,4 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService @@ -80,10 +107,4 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
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 @@ -111,7 +111,7 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
_jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(

10
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -209,18 +209,18 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -209,18 +209,18 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
Option<TEtag> maybeExisting =
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);
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))
{
return false;
}
}
else if (existingItemId == MediaServerItemId(incoming))
else if (existingEtag == MediaServerEtag(incoming))
{
// item is unchanged, but file does not exist
// don't scan, but mark as unavailable
@ -277,7 +277,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -277,7 +277,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{
TMovie existing = result.Item;
if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) ||
if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0)
{
if (_localFileSystem.FileExists(result.LocalPath))

10
ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -439,18 +439,18 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -439,18 +439,18 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
}
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);
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))
{
return false;
}
}
else if (existingItemId == MediaServerItemId(incoming))
else if (existingEtag == MediaServerEtag(incoming))
{
// item is unchanged, but file does not exist
// don't scan, but mark as unavailable
@ -559,7 +559,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -559,7 +559,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{
TEpisode existing = result.Item;
if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) ||
if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0)
{
if (_localFileSystem.FileExists(result.LocalPath))

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

@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations; @@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class EmbyLibraryConfiguration : IEntityTypeConfiguration<EmbyLibrary>
{
public void Configure(EntityTypeBuilder<EmbyLibrary> builder) =>
public void Configure(EntityTypeBuilder<EmbyLibrary> builder)
{
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; @@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary>
{
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder) =>
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder)
{
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 @@ -152,6 +152,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
try
{
// blank out etag for initial save in case other updates fail
string etag = movie.Etag;
movie.Etag = string.Empty;
movie.LibraryPathId = library.Paths.Head().Id;
@ -159,6 +160,9 @@ public class EmbyMovieRepository : IEmbyMovieRepository @@ -159,6 +160,9 @@ public class EmbyMovieRepository : IEmbyMovieRepository
await dbContext.AddAsync(movie);
await dbContext.SaveChangesAsync();
// restore etag
movie.Etag = etag;
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyMovie>(movie) { IsAdded = true };

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

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

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

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

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

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

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

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -159,26 +163,50 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -159,26 +163,50 @@ public class MediaSourceRepository : IMediaSourceRepository
public async Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete)
List<JellyfinLibrary> toDelete,
List<JellyfinLibrary> toUpdate)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (JellyfinLibrary add in toAdd)
{
add.MediaSourceId = jellyfinMediaSourceId;
dbContext.Entry(add).State = EntityState.Added;
foreach (LibraryPath path in add.Paths)
dbContext.JellyfinLibraries.Add(add);
}
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();
@ -188,26 +216,50 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -188,26 +216,50 @@ public class MediaSourceRepository : IMediaSourceRepository
public async Task<List<int>> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete)
List<EmbyLibrary> toDelete,
List<EmbyLibrary> toUpdate)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (EmbyLibrary add in toAdd)
{
add.MediaSourceId = embyMediaSourceId;
dbContext.Entry(add).State = EntityState.Added;
foreach (LibraryPath path in add.Paths)
dbContext.EmbyLibraries.Add(add);
}
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();
@ -259,14 +311,16 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -259,14 +311,16 @@ public class MediaSourceRepository : IMediaSourceRepository
List<PlexLibrary> allPlexLibraries = await dbContext.PlexLibraries.ToListAsync();
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> showIds = await dbContext.PlexShows.Map(ps => ps.Id).ToListAsync();
List<int> episodeIds = await dbContext.PlexEpisodes.Map(pe => pe.Id).ToListAsync();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList();
return deletedMediaIds;
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
@ -292,83 +346,30 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -292,83 +346,30 @@ public class MediaSourceRepository : IMediaSourceRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
new { ids = libraryIds });
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> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
@"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 }).Map(result => result.ToList());
List<PlexLibrary> libraries = await dbContext.PlexLibraries
.Include(l => l.Paths)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(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 });
dbContext.PlexLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
@"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 }).Map(result => result.ToList());
foreach (PlexLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
library.LastScan = SystemTime.MinValueUtc;
}
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(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 });
await dbContext.PlexLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
return deletedMediaIds;
}
public async Task EnablePlexLibrarySync(IEnumerable<int> libraryIds)
@ -447,6 +448,7 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -447,6 +448,7 @@ public class MediaSourceRepository : IMediaSourceRepository
return await context.JellyfinMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.ThenInclude(l => (l as JellyfinLibrary).PathInfos)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
@ -473,83 +475,31 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -473,83 +475,31 @@ public class MediaSourceRepository : IMediaSourceRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
new { ids = libraryIds });
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> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason js ON js.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());
List<JellyfinLibrary> libraries = await dbContext.JellyfinLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(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 });
dbContext.JellyfinLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
@"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 }).Map(result => result.ToList());
foreach (JellyfinLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
library.LastScan = SystemTime.MinValueUtc;
}
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(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 });
await dbContext.JellyfinLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
return deletedMediaIds;
}
public async Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@ -557,6 +507,8 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -557,6 +507,8 @@ public class MediaSourceRepository : IMediaSourceRepository
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
return await context.JellyfinLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Include(l => l.MediaSource)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
.Map(Optional);
@ -654,24 +606,14 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -654,24 +606,14 @@ public class MediaSourceRepository : IMediaSourceRepository
var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList();
dbContext.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
List<int> movieIds = await dbContext.JellyfinMovies
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(pm => pm.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)
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList();
return deletedMediaIds;
}
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
@ -742,10 +684,9 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -742,10 +684,9 @@ public class MediaSourceRepository : IMediaSourceRepository
return await context.EmbyMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.ThenInclude(l => (l as EmbyLibrary).PathInfos)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
.SelectOneAsync(s => s.Id, s => s.Id == id);
}
public async Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId)
@ -771,9 +712,9 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -771,9 +712,9 @@ public class MediaSourceRepository : IMediaSourceRepository
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EmbyLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == embyLibraryId)
.Map(Optional);
.Include(l => l.PathInfos)
.Include(l => l.MediaSource)
.SelectOneAsync(l => l.Id, l => l.Id == embyLibraryId);
}
public async Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId)
@ -858,24 +799,14 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -858,24 +799,14 @@ public class MediaSourceRepository : IMediaSourceRepository
var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList();
dbContext.EmbyLibraries.RemoveRange(allEmbyLibraries);
List<int> movieIds = await dbContext.EmbyMovies
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(pm => pm.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)
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return movieIds.Append(showIds).Append(episodeIds).ToList();
return deletedMediaIds;
}
public async Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds)
@ -890,122 +821,30 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -890,122 +821,30 @@ public class MediaSourceRepository : IMediaSourceRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
new { ids = libraryIds });
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> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
@"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 }).Map(result => result.ToList());
List<EmbyLibrary> libraries = await dbContext.EmbyLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyShow WHERE Id IN
(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 });
dbContext.EmbyLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Show WHERE Id IN
(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 });
foreach (EmbyLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
library.LastScan = SystemTime.MinValueUtc;
}
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(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.EmbyLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
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 @@ -147,6 +147,7 @@ public class PlexMovieRepository : IPlexMovieRepository
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
@ -154,6 +155,9 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -154,6 +155,9 @@ public class PlexMovieRepository : IPlexMovieRepository
await dbContext.PlexMovies.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };

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

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

62
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Emby; @@ -12,6 +12,7 @@ namespace ErsatzTV.Infrastructure.Emby;
public class EmbyApiClient : IEmbyApiClient
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<EmbyApiClient> _logger;
private readonly IMemoryCache _memoryCache;
@ -19,10 +20,12 @@ public class EmbyApiClient : IEmbyApiClient @@ -19,10 +20,12 @@ public class EmbyApiClient : IEmbyApiClient
public EmbyApiClient(
IFallbackMetadataProvider fallbackMetadataProvider,
IMemoryCache memoryCache,
IEmbyPathReplacementService embyPathReplacementService,
ILogger<EmbyApiClient> logger)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
_memoryCache = memoryCache;
_embyPathReplacementService = embyPathReplacementService;
_logger = logger;
}
@ -71,14 +74,14 @@ public class EmbyApiClient : IEmbyApiClient @@ -71,14 +74,14 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
string libraryId)
EmbyLibrary library)
{
try
{
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, libraryId);
EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, library.ItemId);
return items.Items
.Map(ProjectToMovie)
.Map(i => ProjectToMovie(library, i))
.Somes()
.ToList();
}
@ -134,6 +137,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -134,6 +137,7 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
string address,
string apiKey,
EmbyLibrary library,
string seasonId)
{
try
@ -141,7 +145,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -141,7 +145,7 @@ public class EmbyApiClient : IEmbyApiClient
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId);
return items.Items
.Map(ProjectToEpisode)
.Map(i => ProjectToEpisode(library, i))
.Somes()
.ToList();
}
@ -245,7 +249,13 @@ public class EmbyApiClient : IEmbyApiClient @@ -245,7 +249,13 @@ public class EmbyApiClient : IEmbyApiClient
Name = response.Name,
MediaKind = LibraryMediaKind.Shows,
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
{
@ -253,7 +263,13 @@ public class EmbyApiClient : IEmbyApiClient @@ -253,7 +263,13 @@ public class EmbyApiClient : IEmbyApiClient
Name = response.Name,
MediaKind = LibraryMediaKind.Movies,
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
"boxsets" => CacheCollectionLibraryId(response.ItemId),
@ -266,7 +282,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -266,7 +282,7 @@ public class EmbyApiClient : IEmbyApiClient
return None;
}
private Option<EmbyMovie> ProjectToMovie(EmbyLibraryItemResponse item)
private Option<EmbyMovie> ProjectToMovie(EmbyLibrary library, EmbyLibraryItemResponse item)
{
try
{
@ -275,6 +291,19 @@ public class EmbyApiClient : IEmbyApiClient @@ -275,6 +291,19 @@ public class EmbyApiClient : IEmbyApiClient
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
{
Name = "Main",
@ -284,7 +313,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -284,7 +313,7 @@ public class EmbyApiClient : IEmbyApiClient
{
new()
{
Path = item.Path
Path = path
}
},
Streams = new List<MediaStream>()
@ -568,7 +597,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -568,7 +597,7 @@ public class EmbyApiClient : IEmbyApiClient
}
}
private Option<EmbyEpisode> ProjectToEpisode(EmbyLibraryItemResponse item)
private Option<EmbyEpisode> ProjectToEpisode(EmbyLibrary library, EmbyLibraryItemResponse item)
{
try
{
@ -577,6 +606,19 @@ public class EmbyApiClient : IEmbyApiClient @@ -577,6 +606,19 @@ public class EmbyApiClient : IEmbyApiClient
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
{
Name = "Main",
@ -586,7 +628,7 @@ public class EmbyApiClient : IEmbyApiClient @@ -586,7 +628,7 @@ public class EmbyApiClient : IEmbyApiClient
{
new()
{
Path = item.Path
Path = path
}
},
Streams = new List<MediaStream>()

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

@ -0,0 +1,6 @@ @@ -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 @@ -5,4 +5,5 @@ public class EmbyLibraryResponse
public string Name { get; set; }
public string CollectionType { get; set; }
public string ItemId { get; set; }
public EmbyLibraryOptionsResponse LibraryOptions { get; set; }
}

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

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

68
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -13,16 +13,19 @@ namespace ErsatzTV.Infrastructure.Jellyfin; @@ -13,16 +13,19 @@ namespace ErsatzTV.Infrastructure.Jellyfin;
public class JellyfinApiClient : IJellyfinApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<JellyfinApiClient> _logger;
private readonly IMemoryCache _memoryCache;
public JellyfinApiClient(
IMemoryCache memoryCache,
IFallbackMetadataProvider fallbackMetadataProvider,
IJellyfinPathReplacementService jellyfinPathReplacementService,
ILogger<JellyfinApiClient> logger)
{
_memoryCache = memoryCache;
_fallbackMetadataProvider = fallbackMetadataProvider;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_logger = logger;
}
@ -91,17 +94,16 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -91,17 +94,16 @@ public class JellyfinApiClient : IJellyfinApiClient
public async Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId)
JellyfinLibrary library)
{
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);
JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, libraryId);
JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, library.ItemId);
return items.Items
.Map(ProjectToMovie)
.Map(i => ProjectToMovie(library, i))
.Somes()
.ToList();
}
@ -172,17 +174,17 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -172,17 +174,17 @@ public class JellyfinApiClient : IJellyfinApiClient
public async Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
string address,
string apiKey,
int mediaSourceId,
JellyfinLibrary library,
string seasonId)
{
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);
JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId);
return items.Items
.Map(ProjectToEpisode)
.Map(i => ProjectToEpisode(library, i))
.Somes()
.ToList();
}
@ -300,7 +302,13 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -300,7 +302,13 @@ public class JellyfinApiClient : IJellyfinApiClient
Name = response.Name,
MediaKind = LibraryMediaKind.Shows,
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
{
@ -308,7 +316,13 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -308,7 +316,13 @@ public class JellyfinApiClient : IJellyfinApiClient
Name = response.Name,
MediaKind = LibraryMediaKind.Movies,
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
"boxsets" => CacheCollectionLibraryId(response.ItemId),
@ -321,7 +335,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -321,7 +335,7 @@ public class JellyfinApiClient : IJellyfinApiClient
return None;
}
private Option<JellyfinMovie> ProjectToMovie(JellyfinLibraryItemResponse item)
private Option<JellyfinMovie> ProjectToMovie(JellyfinLibrary library, JellyfinLibraryItemResponse item)
{
try
{
@ -336,6 +350,19 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -336,6 +350,19 @@ public class JellyfinApiClient : IJellyfinApiClient
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
{
Name = "Main",
@ -345,7 +372,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -345,7 +372,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
new()
{
Path = item.Path
Path = path
}
},
Streams = new List<MediaStream>()
@ -647,7 +674,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -647,7 +674,7 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibraryItemResponse item)
private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibrary library, JellyfinLibraryItemResponse item)
{
try
{
@ -662,6 +689,19 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -662,6 +689,19 @@ public class JellyfinApiClient : IJellyfinApiClient
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
{
Name = "Main",
@ -671,7 +711,7 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -671,7 +711,7 @@ public class JellyfinApiClient : IJellyfinApiClient
{
new()
{
Path = item.Path
Path = path
}
},
Streams = new List<MediaStream>()

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

@ -0,0 +1,6 @@ @@ -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 @@ -5,4 +5,5 @@ public class JellyfinLibraryResponse
public string Name { get; set; }
public string CollectionType { get; set; }
public string ItemId { get; set; }
public JellyfinLibraryOptionsResponse LibraryOptions { get; set; }
}

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

@ -0,0 +1,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -2132,6 +2132,50 @@ namespace ErsatzTV.Infrastructure.Migrations
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 =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -3504,6 +3548,22 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3504,6 +3548,22 @@ namespace ErsatzTV.Infrastructure.Migrations
.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 =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -4063,6 +4123,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -4063,6 +4123,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideos");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyLibrary", b =>
{
b.Navigation("PathInfos");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyMediaSource", b =>
{
b.Navigation("Connections");
@ -4077,6 +4142,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -4077,6 +4142,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinLibrary", b =>
{
b.Navigation("PathInfos");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinMediaSource", b =>
{
b.Navigation("Connections");

5
ErsatzTV/Pages/EmbyLibrariesEditor.razor

@ -45,9 +45,10 @@ @@ -45,9 +45,10 @@
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;
}

5
ErsatzTV/Pages/JellyfinLibrariesEditor.razor

@ -45,9 +45,10 @@ @@ -45,9 +45,10 @@
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;
}

2
ErsatzTV/Pages/Libraries.razor

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

4
ErsatzTV/Pages/PlexLibrariesEditor.razor

@ -45,9 +45,9 @@ @@ -45,9 +45,9 @@
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;
}

33
ErsatzTV/Services/EmbyService.cs

@ -50,9 +50,6 @@ public class EmbyService : BackgroundService @@ -50,9 +50,6 @@ public class EmbyService : BackgroundService
case SynchronizeEmbyMediaSources synchronizeEmbyMediaSources:
requestTask = SynchronizeSources(synchronizeEmbyMediaSources, cancellationToken);
break;
// case SynchronizeEmbyAdminUserId synchronizeEmbyAdminUserId:
// requestTask = SynchronizeAdminUserId(synchronizeEmbyAdminUserId, cancellationToken);
// break;
case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, cancellationToken);
break;
@ -93,9 +90,7 @@ public class EmbyService : BackgroundService @@ -93,9 +90,7 @@ public class EmbyService : BackgroundService
}
}
private async Task SynchronizeSources(
SynchronizeEmbyMediaSources request,
CancellationToken cancellationToken)
private async Task SynchronizeSources(SynchronizeEmbyMediaSources request, CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
@ -117,9 +112,7 @@ public class EmbyService : BackgroundService @@ -117,9 +112,7 @@ public class EmbyService : BackgroundService
});
}
private async Task SynchronizeLibraries(
SynchronizeEmbyLibraries request,
CancellationToken cancellationToken)
private async Task SynchronizeLibraries(SynchronizeEmbyLibraries request, CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
@ -135,27 +128,7 @@ public class EmbyService : BackgroundService @@ -135,27 +128,7 @@ public class EmbyService : BackgroundService
error.Value));
}
// private async Task SynchronizeAdminUserId(
// 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)
private async Task SynchronizeEmbyLibrary(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();

4
ErsatzTV/Services/JellyfinService.cs

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

55
ErsatzTV/Services/SchedulerService.cs

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

2
ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor

@ -70,7 +70,7 @@ @@ -70,7 +70,7 @@
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)
.Map(list => list.Filter(ll => ll.MediaKind == MediaKind && ll.Id != SourceLibraryId))

10
ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor

@ -61,11 +61,13 @@ @@ -61,11 +61,13 @@
public Func<List<RemoteMediaSourceLibraryEditViewModel>, IRequest<Either<BaseError, Unit>>> GetUpdateLibraryRequest { get; set; }
[Parameter]
public Func<int, Task<Unit>> SynchronizeLibraryByIdIfNeeded { get; set; }
public Func<SynchronizeParameters, Task<Unit>> SynchronizeLibraryByIdIfNeeded { get; set; }
private RemoteMediaSourceViewModel _source;
private List<RemoteMediaSourceLibraryEditViewModel> _libraries;
public record SynchronizeParameters(int LibraryId, int MediaSourceId);
public void Dispose()
{
_cts.Cancel();
@ -104,11 +106,11 @@ @@ -104,11 +106,11 @@
},
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