Browse Source

plex collection rework (#1503)

* start to rework plex collection scanning

* sync plex collections to db

* sync plex collection items

* update changelog
pull/1504/head
Jason Dove 2 years ago committed by GitHub
parent
commit
b95a89b11f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 14
      ErsatzTV.Application/Libraries/Queries/GetExternalCollectionsHandler.cs
  3. 83
      ErsatzTV.Application/Plex/Commands/CallPlexCollectionScannerHandler.cs
  4. 6
      ErsatzTV.Application/Plex/Commands/SynchronizePlexCollections.cs
  5. 2
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs
  6. 4
      ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs
  7. 12
      ErsatzTV.Core/Domain/Collection/PlexCollection.cs
  8. 1
      ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs
  9. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  10. 12
      ErsatzTV.Core/Interfaces/Plex/IPlexCollectionScanner.cs
  11. 11
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  12. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  13. 13
      ErsatzTV.Core/Interfaces/Repositories/IPlexCollectionRepository.cs
  14. 4455
      ErsatzTV.Infrastructure.MySql/Migrations/20231114145549_Add_PlexCollection.Designer.cs
  15. 52
      ErsatzTV.Infrastructure.MySql/Migrations/20231114145549_Add_PlexCollection.cs
  16. 25
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  17. 4453
      ErsatzTV.Infrastructure.Sqlite/Migrations/20231114145717_Add_PlexCollection.Designer.cs
  18. 47
      ErsatzTV.Infrastructure.Sqlite/Migrations/20231114145717_Add_PlexCollection.cs
  19. 25
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  20. 10
      ErsatzTV.Infrastructure/Data/Configurations/Collection/PlexCollectionConfiguration.cs
  21. 8
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  22. 148
      ErsatzTV.Infrastructure/Data/Repositories/PlexCollectionRepository.cs
  23. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  24. 41
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  25. 36
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  26. 24
      ErsatzTV.Infrastructure/Plex/Models/PlexCollectionItemMetadataResponse.cs
  27. 12
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  28. 30
      ErsatzTV.Infrastructure/Plex/PlexEtag.cs
  29. 125
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  30. 5
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexCollections.cs
  31. 117
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexCollectionsHandler.cs
  32. 125
      ErsatzTV.Scanner/Core/Plex/PlexCollectionScanner.cs
  33. 2
      ErsatzTV.Scanner/Program.cs
  34. 23
      ErsatzTV.Scanner/Worker.cs
  35. 71
      ErsatzTV/Pages/Libraries.razor
  36. 5
      ErsatzTV/Pages/PlexMediaSources.razor
  37. 19
      ErsatzTV/Services/PlexService.cs
  38. 51
      ErsatzTV/Services/ScannerService.cs
  39. 11
      ErsatzTV/Services/SchedulerService.cs
  40. 1
      ErsatzTV/Startup.cs

4
CHANGELOG.md

@ -53,6 +53,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -53,6 +53,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Use hardware acceleration for error messages/offline messages
- Try to parse season number from season folder when Jellyfin does not provide season number
- This *may* fix issues where Jellyfin libraries show all season numbers as 0 (specials)
- Rework Plex collection scanning
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a table in the `Media` > `Libraries` page with a button to manually re-scan Plex collections as needed
- Plex smart collections will now be synchronized as tags, similar to other Plex collections
## [0.8.2-beta] - 2023-09-14
### Added

14
ErsatzTV.Application/Libraries/Queries/GetExternalCollectionsHandler.cs

@ -20,6 +20,7 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti @@ -20,6 +20,7 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
result.AddRange(await GetEmbyExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetJellyfinExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetPlexExternalCollections(dbContext, cancellationToken));
return result;
}
@ -48,4 +49,17 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti @@ -48,4 +49,17 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
return jellyfinMediaSourceIds.Map(
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> plexMediaSourceIds = await dbContext.PlexMediaSources
.Filter(pms => pms.Libraries.Any(l => ((PlexLibrary)l).ShouldSyncItems))
.Map(pms => pms.Id)
.ToListAsync(cancellationToken);
return plexMediaSourceIds.Map(
id => new LibraryViewModel("Plex", 0, "Collections", 0, id, string.Empty));
}
}

83
ErsatzTV.Application/Plex/Commands/CallPlexCollectionScannerHandler.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Plex;
public class CallPlexCollectionScannerHandler : CallLibraryScannerHandler<SynchronizePlexCollections>,
IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
{
public CallPlexCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizePlexCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizePlexCollections request)
{
DateTime minDateTime = await dbContext.PlexMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizePlexCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizePlexCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-plex-collections", request.PlexMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

6
ErsatzTV.Application/Plex/Commands/SynchronizePlexCollections.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

2
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraries.cs

@ -3,4 +3,4 @@ @@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexLibraries(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IPlexBackgroundServiceRequest;
IScannerBackgroundServiceRequest;

4
ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs

@ -15,7 +15,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle @@ -15,7 +15,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
{
private const string LocalhostUri = "http://localhost:32400";
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
@ -28,7 +28,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle @@ -28,7 +28,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
IPlexTvApiClient plexTvApiClient,
IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
ChannelWriter<IScannerBackgroundServiceRequest> channel,
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{

12
ErsatzTV.Core/Domain/Collection/PlexCollection.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix")]
public class PlexCollection
{
public int Id { get; set; }
public string Key { get; set; }
public string Etag { get; set; }
public string Name { get; set; }
}

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

@ -11,4 +11,5 @@ public class PlexMediaSource : MediaSource @@ -11,4 +11,5 @@ public class PlexMediaSource : MediaSource
// public bool IsOwned { get; set; }
public List<PlexConnection> Connections { get; set; }
public List<PlexPathReplacement> PathReplacements { get; set; }
public DateTime? LastCollectionsScan { get; set; }
}

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -8,6 +8,7 @@ public interface IEntityLocker @@ -8,6 +8,7 @@ public interface IEntityLocker
event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
@ -27,6 +28,9 @@ public interface IEntityLocker @@ -27,6 +28,9 @@ public interface IEntityLocker
bool LockJellyfinCollections();
bool UnlockJellyfinCollections();
bool AreJellyfinCollectionsLocked();
bool LockPlexCollections();
bool UnlockPlexCollections();
bool ArePlexCollectionsLocked();
bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);

12
ErsatzTV.Core/Interfaces/Plex/IPlexCollectionScanner.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexCollectionScanner
{
Task<Either<BaseError, Unit>> ScanCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken);
}

11
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -67,4 +67,15 @@ public interface IPlexServerApiClient @@ -67,4 +67,15 @@ public interface IPlexServerApiClient
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<PlexCollection> GetAllCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken);
IAsyncEnumerable<MediaItem> GetCollectionItems(
PlexConnection connection,
PlexServerAuthToken token,
string key,
CancellationToken cancellationToken);
}

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

@ -84,4 +84,5 @@ public interface IMediaSourceRepository @@ -84,4 +84,5 @@ public interface IMediaSourceRepository
Task<List<int>> DisableEmbyLibrarySync(List<int> libraryIds);
Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource);
Task<Unit> UpdateLastCollectionScan(PlexMediaSource plexMediaSource);
}

13
ErsatzTV.Core/Interfaces/Repositories/IPlexCollectionRepository.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexCollectionRepository
{
Task<List<PlexCollection>> GetCollections();
Task<bool> AddCollection(PlexCollection collection);
Task<bool> RemoveCollection(PlexCollection collection);
Task<List<int>> RemoveAllTags(PlexCollection collection);
Task<int> AddTag(MediaItem item, PlexCollection collection);
Task<bool> SetEtag(PlexCollection collection);
}

4455
ErsatzTV.Infrastructure.MySql/Migrations/20231114145549_Add_PlexCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

52
ErsatzTV.Infrastructure.MySql/Migrations/20231114145549_Add_PlexCollection.cs

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlexCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastCollectionsScan",
table: "PlexMediaSource",
type: "datetime(6)",
nullable: true);
migrationBuilder.CreateTable(
name: "PlexCollection",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Key = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Etag = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_PlexCollection", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlexCollection");
migrationBuilder.DropColumn(
name: "LastCollectionsScan",
table: "PlexMediaSource");
}
}
}

25
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -16,7 +16,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -16,7 +16,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.11")
.HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
@ -1538,6 +1538,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1538,6 +1538,26 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("PlayoutProgramScheduleAnchor", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Etag")
.HasColumnType("longtext");
b.Property<string>("Key")
.HasColumnType("longtext");
b.Property<string>("Name")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("PlexCollection", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
{
b.Property<int>("Id")
@ -2497,6 +2517,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2497,6 +2517,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("ClientIdentifier")
.HasColumnType("longtext");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("datetime(6)");
b.Property<string>("Platform")
.HasColumnType("longtext");

4453
ErsatzTV.Infrastructure.Sqlite/Migrations/20231114145717_Add_PlexCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

47
ErsatzTV.Infrastructure.Sqlite/Migrations/20231114145717_Add_PlexCollection.cs

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlexCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastCollectionsScan",
table: "PlexMediaSource",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "PlexCollection",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Key = table.Column<string>(type: "TEXT", nullable: true),
Etag = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexCollection", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlexCollection");
migrationBuilder.DropColumn(
name: "LastCollectionsScan",
table: "PlexMediaSource");
}
}
}

25
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.11");
modelBuilder.HasAnnotation("ProductVersion", "7.0.13");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -1536,6 +1536,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1536,6 +1536,26 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("PlayoutProgramScheduleAnchor", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PlexCollection", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
{
b.Property<int>("Id")
@ -2495,6 +2515,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2495,6 +2515,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.HasColumnType("TEXT");

10
ErsatzTV.Infrastructure/Data/Configurations/Collection/PlexCollectionConfiguration.cs

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

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

@ -873,4 +873,12 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -873,4 +873,12 @@ public class MediaSourceRepository : IMediaSourceRepository
"UPDATE JellyfinMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { jellyfinMediaSource.LastCollectionsScan, jellyfinMediaSource.Id }).ToUnit();
}
public async Task<Unit> UpdateLastCollectionScan(PlexMediaSource plexMediaSource)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { plexMediaSource.LastCollectionsScan, plexMediaSource.Id }).ToUnit();
}
}

148
ErsatzTV.Infrastructure/Data/Repositories/PlexCollectionRepository.cs

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class PlexCollectionRepository : IPlexCollectionRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public PlexCollectionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<PlexCollection>> GetCollections()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.PlexCollections.ToListAsync();
}
public async Task<bool> AddCollection(PlexCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(collection);
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> RemoveCollection(PlexCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
dbContext.Remove(collection);
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @Key",
new { collection.Name, collection.Key });
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<List<int>> RemoveAllTags(PlexCollection collection)
{
var result = new List<int>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// movies
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN PlexMovie JM on JM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
// shows
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN PlexShow JS on JS.Id = SM.ShowId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
// seasons
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN PlexSeason JS on JS.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
// episodes
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN PlexEpisode JE on JE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @Key",
new { collection.Name, collection.Key });
return result;
}
public async Task<int> AddTag(MediaItem item, PlexCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
switch (item)
{
case PlexMovie movie:
int movieId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexMovie WHERE `Key` = @Key",
new { movie.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @Key, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId) AS A",
new { collection.Name, collection.Key, MovieId = movieId });
return movieId;
case PlexShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexShow WHERE `Key` = @Key",
new { show.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @Key, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId) AS A",
new { collection.Name, collection.Key, ShowId = showId });
return showId;
case PlexSeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexSeason WHERE `Key` = @Key",
new { season.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @Key, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId) AS A",
new { collection.Name, collection.Key, SeasonId = seasonId });
return seasonId;
case PlexEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexEpisode WHERE `Key` = @Key",
new { episode.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @Key, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId) AS A",
new { collection.Name, collection.Key, EpisodeId = episodeId });
return episodeId;
default:
return 0;
}
}
public async Task<bool> SetEtag(PlexCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE PlexCollection SET Etag = @Etag WHERE `Key` = @Key",
new { collection.Etag, collection.Key }) > 0;
}
}

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -62,6 +62,7 @@ public class TvContext : DbContext @@ -62,6 +62,7 @@ public class TvContext : DbContext
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<PlexCollection> PlexCollections { get; set; }
public DbSet<JellyfinMovie> JellyfinMovies { get; set; }
public DbSet<JellyfinShow> JellyfinShows { get; set; }
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }

41
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -5,27 +5,22 @@ namespace ErsatzTV.Infrastructure.Locking; @@ -5,27 +5,22 @@ namespace ErsatzTV.Infrastructure.Locking;
public class EntityLocker : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedLibraries;
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts;
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes;
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
private bool _embyCollections;
private bool _jellyfinCollections;
private bool _plexCollections;
private bool _plex;
private bool _trakt;
public EntityLocker()
{
_lockedLibraries = new ConcurrentDictionary<int, byte>();
_lockedPlayouts = new ConcurrentDictionary<int, byte>();
_lockedRemoteMediaSourceTypes = new ConcurrentDictionary<Type, byte>();
}
public event EventHandler OnLibraryChanged;
public event EventHandler OnPlexChanged;
public event EventHandler<Type> OnRemoteMediaSourceChanged;
public event EventHandler OnTraktChanged;
public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler OnPlexCollectionsChanged;
public event EventHandler<int> OnPlayoutChanged;
public bool LockLibrary(int libraryId)
@ -186,6 +181,32 @@ public class EntityLocker : IEntityLocker @@ -186,6 +181,32 @@ public class EntityLocker : IEntityLocker
}
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
public bool LockPlexCollections()
{
if (!_plexCollections)
{
_plexCollections = true;
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockPlexCollections()
{
if (_plexCollections)
{
_plexCollections = false;
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool ArePlexCollectionsLocked() => _plexCollections;
public bool LockPlayout(int playoutId)
{

36
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -35,6 +35,42 @@ public interface IPlexServerApi @@ -35,6 +35,42 @@ public interface IPlexServerApi
int take,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/all?type=18&X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
[Headers("Accept: text/xml")]
public Task<PlexXmlMediaContainerStatsResponse> GetCollectionCount(
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/all?type=18")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetCollections(
[Query] [AliasAs("X-Plex-Container-Start")]
int skip,
[Query] [AliasAs("X-Plex-Container-Size")]
int take,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/collections/{key}/children?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
[Headers("Accept: text/xml")]
public Task<PlexXmlMediaContainerStatsResponse> GetCollectionItemsCount(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/collections/{key}/children")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexCollectionItemMetadataResponse>>>
GetCollectionItems(
string key,
[Query] [AliasAs("X-Plex-Container-Start")]
int skip,
[Query] [AliasAs("X-Plex-Container-Size")]
int take,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}?includeChapters=1")]
[Headers("Accept: text/xml")]

24
ErsatzTV.Infrastructure/Plex/Models/PlexCollectionItemMetadataResponse.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexCollectionItemMetadataResponse
{
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("ratingKey")]
public string RatingKey { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("addedAt")]
public long AddedAt { get; set; }
[XmlAttribute("updatedAt")]
public long UpdatedAt { get; set; }
[XmlAttribute("type")]
public string Type { get; set; }
}

12
ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs

@ -7,6 +7,9 @@ public class PlexMetadataResponse @@ -7,6 +7,9 @@ public class PlexMetadataResponse
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("ratingKey")]
public string RatingKey { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
@ -36,6 +39,15 @@ public class PlexMetadataResponse @@ -36,6 +39,15 @@ public class PlexMetadataResponse
[XmlAttribute("updatedAt")]
public long UpdatedAt { get; set; }
[XmlAttribute("childCount")]
public string ChildCount { get; set; }
[XmlAttribute("smart")]
public string Smart { get; set; }
[XmlAttribute("librarySectionId")]
public int LibrarySectionId { get; set; }
[XmlAttribute("index")]
public int Index { get; set; }

30
ErsatzTV.Infrastructure/Plex/PlexEtag.cs

@ -253,6 +253,31 @@ public class PlexEtag @@ -253,6 +253,31 @@ public class PlexEtag
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
public string ForCollection(PlexMetadataResponse response)
{
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
using var bw = new BinaryWriter(ms);
// collection key
bw.Write(response.Key);
// collection added at
bw.Write(response.AddedAt);
// collection updated at
bw.Write(response.UpdatedAt);
// collection child count
bw.Write(response.ChildCount ?? "0");
// collection is smart collection
bw.Write(response.Smart ?? "0");
ms.Position = 0;
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
private enum FieldKey : byte
{
@ -270,6 +295,9 @@ public class PlexEtag @@ -270,6 +295,9 @@ public class PlexEtag
Thumb = 20,
Art = 21,
File = 30
File = 30,
ChildCount = 40,
Smart = 41 // smart collection bool
}
}

125
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -290,6 +290,49 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -290,6 +290,49 @@ public class PlexServerApiClient : IPlexServerApiClient
}
}
public IAsyncEnumerable<PlexCollection> GetAllCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken)
{
return GetPagedLibraryContents(connection, CountItems, GetItems);
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
{
return service.GetCollectionCount(token.AuthToken);
}
Task<IEnumerable<PlexCollection>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
{
return jsonService
.GetCollections(skip, pageSize, token.AuthToken)
.Map(r => r.MediaContainer.Metadata)
.Map(list => list.Map(m => ProjectToCollection(connection.PlexMediaSource, m)).Somes());
}
}
public IAsyncEnumerable<MediaItem> GetCollectionItems(
PlexConnection connection,
PlexServerAuthToken token,
string key,
CancellationToken cancellationToken)
{
return GetPagedLibraryContents(connection, CountItems, GetItems);
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
{
return service.GetCollectionItemsCount(key, token.AuthToken);
}
Task<IEnumerable<MediaItem>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
{
return jsonService
.GetCollectionItems(key, skip, pageSize, token.AuthToken)
.Map(r => Optional(r.MediaContainer.Metadata).Flatten())
.Map(list => list.Map(ProjectToCollectionMediaItem).Somes());
}
}
private static async IAsyncEnumerable<TItem> GetPagedLibraryContents<TItem>(
PlexConnection connection,
Func<IPlexServerApi, Task<PlexXmlMediaContainerStatsResponse>> countItems,
@ -364,6 +407,52 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -364,6 +407,52 @@ public class PlexServerApiClient : IPlexServerApiClient
_ => None
};
private Option<PlexCollection> ProjectToCollection(PlexMediaSource plexMediaSource, PlexMetadataResponse item)
{
try
{
// skip collections in libraries that are not synchronized
if (plexMediaSource.Libraries.OfType<PlexLibrary>().Any(
l => l.Key == item.LibrarySectionId.ToString(CultureInfo.InvariantCulture) &&
l.ShouldSyncItems == false))
{
return Option<PlexCollection>.None;
}
return new PlexCollection
{
Key = item.RatingKey,
Etag = _plexEtag.ForCollection(item),
Name = item.Title
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Plex collection");
return None;
}
}
private Option<MediaItem> ProjectToCollectionMediaItem(PlexCollectionItemMetadataResponse item)
{
try
{
return item.Type switch
{
"movie" => new PlexMovie { Key = item.Key },
"show" => new PlexShow { Key = item.Key },
"season" => new PlexSeason { Key = item.Key },
"episode" => new PlexEpisode { Key = item.Key },
_ => None
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Plex collection media item");
return None;
}
}
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse<PlexPartResponse> media = response.Media
@ -461,15 +550,6 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -461,15 +550,6 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.Guids = new List<MetadataGuid>();
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(
new Tag
{
Name = collection.Tag, ExternalCollectionId = collection.Id.ToString(CultureInfo.InvariantCulture)
});
}
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
@ -699,15 +779,6 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -699,15 +779,6 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.Studios.Add(new Studio { Name = response.Studio });
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(
new Tag
{
Name = collection.Tag, ExternalCollectionId = collection.Id.ToString(CultureInfo.InvariantCulture)
});
}
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
@ -781,15 +852,6 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -781,15 +852,6 @@ public class PlexServerApiClient : IPlexServerApiClient
}
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(
new Tag
{
Name = collection.Tag, ExternalCollectionId = collection.Id.ToString(CultureInfo.InvariantCulture)
});
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
@ -935,15 +997,6 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -935,15 +997,6 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.ReleaseDate = releaseDate;
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(
new Tag
{
Name = collection.Tag, ExternalCollectionId = collection.Id.ToString(CultureInfo.InvariantCulture)
});
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";

5
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexCollections.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Scanner.Application.Plex;
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;

117
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexCollectionsHandler.cs

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Scanner.Application.Plex;
public class SynchronizePlexCollectionsHandler : IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexCollectionScanner _scanner;
public SynchronizePlexCollectionsHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IPlexCollectionScanner scanner,
IConfigElementRepository configElementRepository)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_scanner = scanner;
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, Unit>> Handle(
SynchronizePlexCollections request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
p => SynchronizeCollections(p, cancellationToken),
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexCollections request)
{
Task<Validation<BaseError, ConnectionParameters>> mediaSource = MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveToken);
return (await mediaSource, await ValidateLibraryRefreshInterval())
.Apply(
(connectionParameters, libraryRefreshInterval) => new RequestParameters(
connectionParameters,
connectionParameters.PlexMediaSource,
request.ForceScan,
libraryRefreshInterval));
}
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, PlexMediaSource>> MediaSourceMustExist(
SynchronizePlexCollections request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Plex media source does not exist."));
private static Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
{
Option<PlexConnection> maybeConnection = plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
ConnectionParameters connectionParameters)
{
Option<PlexServerAuthToken> maybeToken = await
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
.ToValidation<BaseError>("Plex media source requires a token");
}
private async Task<Either<BaseError, Unit>> SynchronizeCollections(
RequestParameters parameters,
CancellationToken cancellationToken)
{
var lastScan = new DateTimeOffset(
parameters.MediaSource.LastCollectionsScan ?? SystemTime.MinValueUtc,
TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || parameters.LibraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now)
{
Either<BaseError, Unit> result = await _scanner.ScanCollections(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
cancellationToken);
if (result.IsRight)
{
parameters.MediaSource.LastCollectionsScan = DateTime.UtcNow;
await _mediaSourceRepository.UpdateLastCollectionScan(parameters.MediaSource);
}
return result;
}
return Unit.Default;
}
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexMediaSource MediaSource,
bool ForceScan,
int LibraryRefreshInterval);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
public PlexServerAuthToken? PlexServerAuthToken { get; set; }
}
}

125
ErsatzTV.Scanner/Core/Plex/PlexCollectionScanner.cs

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Plex;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Plex;
public class PlexCollectionScanner : IPlexCollectionScanner
{
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexCollectionRepository _plexCollectionRepository;
private readonly ILogger<PlexCollectionScanner> _logger;
private readonly IMediator _mediator;
public PlexCollectionScanner(
IMediator mediator,
IPlexCollectionRepository plexCollectionRepository,
IPlexServerApiClient plexServerApiClient,
ILogger<PlexCollectionScanner> logger)
{
_mediator = mediator;
_plexCollectionRepository = plexCollectionRepository;
_plexServerApiClient = plexServerApiClient;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanCollections(
PlexConnection connection,
PlexServerAuthToken token,
CancellationToken cancellationToken)
{
try
{
var incomingKeys = new List<string>();
// get all collections from db (key, etag)
List<PlexCollection> existingCollections = await _plexCollectionRepository.GetCollections();
await foreach (PlexCollection collection in _plexServerApiClient.GetAllCollections(
connection,
token,
cancellationToken))
{
incomingKeys.Add(collection.Key);
Option<PlexCollection> maybeExisting = existingCollections.Find(c => c.Key == collection.Key);
// skip if unchanged (etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
collection.Etag)
{
_logger.LogDebug("Plex collection {Name} is unchanged", collection.Name);
continue;
}
// add if new
if (maybeExisting.IsNone)
{
_logger.LogDebug("Plex collection {Name} is new", collection.Name);
await _plexCollectionRepository.AddCollection(collection);
}
await SyncCollectionItems(connection, token, collection, cancellationToken);
// save collection etag
await _plexCollectionRepository.SetEtag(collection);
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (PlexCollection collection in existingCollections.Filter(e => !incomingKeys.Contains(e.Key)))
{
await _plexCollectionRepository.RemoveCollection(collection);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get collections from Plex");
return BaseError.New(ex.Message);
}
return Unit.Default;
}
private async Task SyncCollectionItems(
PlexConnection connection,
PlexServerAuthToken token,
PlexCollection collection,
CancellationToken cancellationToken)
{
try
{
// get collection items from Plex
IAsyncEnumerable<MediaItem> items = _plexServerApiClient.GetCollectionItems(
connection,
token,
collection.Key,
cancellationToken);
List<int> removedIds = await _plexCollectionRepository.RemoveAllTags(collection);
// sync tags on items
var addedIds = new List<int>();
await foreach (MediaItem item in items)
{
addedIds.Add(await _plexCollectionRepository.AddTag(item, collection));
cancellationToken.ThrowIfCancellationRequested();
}
_logger.LogDebug("Plex collection {Name} contains {Count} items", collection.Name, addedIds.Count);
int[] changedIds = removedIds.Concat(addedIds).Distinct().ToArray();
await _mediator.Publish(
new ScannerProgressUpdate(0, null, null, changedIds.ToArray(), Array.Empty<int>()),
CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to synchronize Plex collection {Name}", collection.Name);
}
}
}

2
ErsatzTV.Scanner/Program.cs

@ -196,7 +196,9 @@ public class Program @@ -196,7 +196,9 @@ public class Program
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();

23
ErsatzTV.Scanner/Worker.cs

@ -70,6 +70,10 @@ public class Worker : BackgroundService @@ -70,6 +70,10 @@ public class Worker : BackgroundService
scanPlexCommand.AddOption(forceOption);
scanPlexCommand.AddOption(deepOption);
var scanPlexCollectionsCommand = new Command("scan-plex-collections", "Scan Plex collections");
scanPlexCollectionsCommand.AddArgument(mediaSourceIdArgument);
scanPlexCollectionsCommand.AddOption(forceOption);
var scanEmbyCommand = new Command("scan-emby", "Scan an Emby library");
scanEmbyCommand.AddArgument(libraryIdArgument);
scanEmbyCommand.AddOption(forceOption);
@ -124,6 +128,24 @@ public class Worker : BackgroundService @@ -124,6 +128,24 @@ public class Worker : BackgroundService
await mediator.Send(scan, context.GetCancellationToken());
}
});
scanPlexCollectionsCommand.SetHandler(
async context =>
{
if (IsScanningEnabled())
{
bool force = context.ParseResult.GetValueForOption(forceOption);
SetProcessPriority(force);
int mediaSourceId = context.ParseResult.GetValueForArgument(mediaSourceIdArgument);
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var scan = new SynchronizePlexCollections(mediaSourceId, force);
await mediator.Send(scan, context.GetCancellationToken());
}
});
scanEmbyCommand.SetHandler(
async context =>
@ -202,6 +224,7 @@ public class Worker : BackgroundService @@ -202,6 +224,7 @@ public class Worker : BackgroundService
var rootCommand = new RootCommand();
rootCommand.AddCommand(scanLocalCommand);
rootCommand.AddCommand(scanPlexCommand);
rootCommand.AddCommand(scanPlexCollectionsCommand);
rootCommand.AddCommand(scanEmbyCommand);
rootCommand.AddCommand(scanEmbyCollectionsCommand);
rootCommand.AddCommand(scanJellyfinCommand);

71
ErsatzTV/Pages/Libraries.razor

@ -8,10 +8,10 @@ @@ -8,10 +8,10 @@
@using ErsatzTV.Application.Jellyfin
@using ErsatzTV.Application.Emby
@implements IDisposable
@inject IMediator _mediator
@inject IEntityLocker _locker
@inject ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
@inject ICourier _courier
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel;
@inject ICourier Courier
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
@ -48,7 +48,7 @@ @@ -48,7 +48,7 @@
<MudTd DataLabel="Media Kind">@context.MediaKind</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
@if (_locker.IsLibraryLocked(context.Id))
@if (Locker.IsLibraryLocked(context.Id))
{
<div style="width: 48px">
@if (_progressByLibrary[context.Id] > 0)
@ -68,7 +68,7 @@ @@ -68,7 +68,7 @@
{
<MudTooltip Text="Deep Scan Library">
<MudIconButton Icon="@Icons.Material.Filled.FindReplace"
Disabled="@_locker.IsLibraryLocked(context.Id)"
Disabled="@Locker.IsLibraryLocked(context.Id)"
OnClick="@(_ => ScanLibrary(context, true))">
</MudIconButton>
</MudTooltip>
@ -79,7 +79,7 @@ @@ -79,7 +79,7 @@
}
<MudTooltip Text="Scan Library">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@_locker.IsLibraryLocked(context.Id)"
Disabled="@Locker.IsLibraryLocked(context.Id)"
OnClick="@(_ => ScanLibrary(context))">
</MudIconButton>
</MudTooltip>
@ -149,42 +149,44 @@ @@ -149,42 +149,44 @@
protected override void OnInitialized()
{
_locker.OnLibraryChanged += LockChanged;
_locker.OnEmbyCollectionsChanged += LockChanged;
_locker.OnJellyfinCollectionsChanged += LockChanged;
_courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
Locker.OnLibraryChanged += LockChanged;
Locker.OnEmbyCollectionsChanged += LockChanged;
Locker.OnJellyfinCollectionsChanged += LockChanged;
Locker.OnPlexCollectionsChanged += LockChanged;
Courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
}
protected override async Task OnParametersSetAsync() => await LoadLibraries(_cts.Token);
private async Task LoadLibraries(CancellationToken cancellationToken)
{
_libraries = await _mediator.Send(new GetConfiguredLibraries(), cancellationToken);
_libraries = await Mediator.Send(new GetConfiguredLibraries(), cancellationToken);
_showServerNames = _libraries.Any(l => l is PlexLibraryViewModel);
_externalCollections = await _mediator.Send(new GetExternalCollections(), cancellationToken);
_externalCollections = await Mediator.Send(new GetExternalCollections(), cancellationToken);
_progressByLibrary = _libraries.ToDictionary(vm => vm.Id, _ => 0);
}
private async Task ScanLibrary(LibraryViewModel library, bool deepScan = false)
{
if (_locker.LockLibrary(library.Id))
if (Locker.LockLibrary(library.Id))
{
switch (library)
{
case LocalLibraryViewModel:
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(library.Id), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(library.Id), _cts.Token);
break;
case PlexLibraryViewModel:
await _scannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token);
break;
case JellyfinLibraryViewModel:
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token);
await _scannerWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id, deepScan), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id, deepScan), _cts.Token);
break;
case EmbyLibraryViewModel:
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(library.MediaSourceId), _cts.Token);
await _scannerWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id, deepScan), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(library.MediaSourceId), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id, deepScan), _cts.Token);
break;
}
@ -197,15 +199,21 @@ @@ -197,15 +199,21 @@
switch (library.LibraryKind.ToLowerInvariant())
{
case "emby":
if (_locker.LockEmbyCollections())
if (Locker.LockEmbyCollections())
{
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyCollections(library.MediaSourceId, true));
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyCollections(library.MediaSourceId, true));
}
break;
case "jellyfin":
if (_locker.LockJellyfinCollections())
if (Locker.LockJellyfinCollections())
{
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinCollections(library.MediaSourceId, true));
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinCollections(library.MediaSourceId, true));
}
break;
case "plex":
if (Locker.LockPlexCollections())
{
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexCollections(library.MediaSourceId, true));
}
break;
}
@ -219,9 +227,11 @@ @@ -219,9 +227,11 @@
switch (libraryKind.ToLowerInvariant())
{
case "emby":
return _locker.AreEmbyCollectionsLocked();
return Locker.AreEmbyCollectionsLocked();
case "jellyfin":
return _locker.AreJellyfinCollectionsLocked();
return Locker.AreJellyfinCollectionsLocked();
case "plex":
return Locker.ArePlexCollectionsLocked();
}
return false;
@ -245,10 +255,11 @@ @@ -245,10 +255,11 @@
void IDisposable.Dispose()
{
_locker.OnLibraryChanged -= LockChanged;
_locker.OnEmbyCollectionsChanged -= LockChanged;
_locker.OnJellyfinCollectionsChanged -= LockChanged;
_courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
Locker.OnLibraryChanged -= LockChanged;
Locker.OnEmbyCollectionsChanged -= LockChanged;
Locker.OnJellyfinCollectionsChanged -= LockChanged;
Locker.OnPlexCollectionsChanged -= LockChanged;
Courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
_cts.Cancel();
_cts.Dispose();

5
ErsatzTV/Pages/PlexMediaSources.razor

@ -9,7 +9,7 @@ @@ -9,7 +9,7 @@
@inject ILogger<PlexMediaSources> _logger
@inject IJSRuntime _jsRuntime
@inject IPlexSecretStore _plexSecretStore
@inject ChannelWriter<IPlexBackgroundServiceRequest> _channel
@inject ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_mediaSources">
@ -149,7 +149,8 @@ @@ -149,7 +149,8 @@
await InvokeAsync(StateHasChanged);
}
private async Task RefreshLibraries(int mediaSourceId) => await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSourceId));
private async Task RefreshLibraries(int mediaSourceId) =>
await _scannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(mediaSourceId));
void IDisposable.Dispose() => _locker.OnPlexChanged -= PlexChanged;

19
ErsatzTV/Services/PlexService.cs

@ -64,9 +64,6 @@ public class PlexService : BackgroundService @@ -64,9 +64,6 @@ public class PlexService : BackgroundService
case SynchronizePlexMediaSources sourcesRequest:
requestTask = SynchronizeSources(sourcesRequest, stoppingToken);
break;
case SynchronizePlexLibraries synchronizePlexLibrariesRequest:
requestTask = SynchronizeLibraries(synchronizePlexLibrariesRequest, stoppingToken);
break;
default:
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
}
@ -150,20 +147,4 @@ public class PlexService : BackgroundService @@ -150,20 +147,4 @@ public class PlexService : BackgroundService
_logger.LogWarning("Unable to poll plex token: {Error}", error.Value);
}
}
private async Task SynchronizeLibraries(SynchronizePlexLibraries 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 plex libraries for source {MediaSourceId}",
request.PlexMediaSourceId),
error => _logger.LogWarning(
"Unable to synchronize plex libraries for source {MediaSourceId}: {Error}",
request.PlexMediaSourceId,
error.Value));
}
}

51
ErsatzTV/Services/ScannerService.cs

@ -43,9 +43,15 @@ public class ScannerService : BackgroundService @@ -43,9 +43,15 @@ public class ScannerService : BackgroundService
Task requestTask;
switch (request)
{
case SynchronizePlexLibraries synchronizePlexLibraries:
requestTask = SynchronizeLibraries(synchronizePlexLibraries, stoppingToken);
break;
case ISynchronizePlexLibraryById synchronizePlexLibraryById:
requestTask = SynchronizePlexLibrary(synchronizePlexLibraryById, stoppingToken);
break;
case SynchronizePlexCollections synchronizePlexCollections:
requestTask = SynchronizePlexCollections(synchronizePlexCollections, stoppingToken);
break;
case SynchronizeJellyfinAdminUserId synchronizeJellyfinAdminUserId:
requestTask = SynchronizeAdminUserId(synchronizeJellyfinAdminUserId, stoppingToken);
break;
@ -132,6 +138,22 @@ public class ScannerService : BackgroundService @@ -132,6 +138,22 @@ public class ScannerService : BackgroundService
entityLocker.UnlockLibrary(request.LibraryId);
}
}
private async Task SynchronizeLibraries(SynchronizePlexLibraries 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 plex libraries for source {MediaSourceId}",
request.PlexMediaSourceId),
error => _logger.LogWarning(
"Unable to synchronize plex libraries for source {MediaSourceId}: {Error}",
request.PlexMediaSourceId,
error.Value));
}
private async Task SynchronizePlexLibrary(
ISynchronizePlexLibraryById request,
@ -166,6 +188,35 @@ public class ScannerService : BackgroundService @@ -166,6 +188,35 @@ public class ScannerService : BackgroundService
entityLocker.UnlockLibrary(request.PlexLibraryId);
}
}
private async Task SynchronizePlexCollections(
SynchronizePlexCollections request,
CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
result.BiIter(
_ => _logger.LogDebug("Done synchronizing plex collections"),
error =>
{
if (error is ScanIsNotRequired)
{
_logger.LogDebug("Scan is not required for plex collections at this time");
}
else
{
_logger.LogWarning("Unable to synchronize plex collections: {Error}", error.Value);
}
});
if (entityLocker.ArePlexCollectionsLocked())
{
entityLocker.UnlockPlexCollections();
}
}
private async Task SynchronizeAdminUserId(
SynchronizeJellyfinAdminUserId request,

11
ErsatzTV/Services/SchedulerService.cs

@ -234,8 +234,12 @@ public class SchedulerService : BackgroundService @@ -234,8 +234,12 @@ public class SchedulerService : BackgroundService
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
var mediaSourceIds = new System.Collections.Generic.HashSet<int>();
foreach (PlexLibrary library in dbContext.PlexLibraries.Filter(l => l.ShouldSyncItems))
{
mediaSourceIds.Add(library.MediaSourceId);
if (_entityLocker.LockLibrary(library.Id))
{
await _scannerWorkerChannel.WriteAsync(
@ -243,6 +247,13 @@ public class SchedulerService : BackgroundService @@ -243,6 +247,13 @@ public class SchedulerService : BackgroundService
cancellationToken);
}
}
foreach (int mediaSourceId in mediaSourceIds)
{
await _scannerWorkerChannel.WriteAsync(
new SynchronizePlexCollections(mediaSourceId, false),
cancellationToken);
}
}
private async Task ScanJellyfinMediaSources(CancellationToken cancellationToken)

1
ErsatzTV/Startup.cs

@ -636,6 +636,7 @@ public class Startup @@ -636,6 +636,7 @@ public class Startup
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>();
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();

Loading…
Cancel
Save