plex collection rework (#1503)
* start to rework plex collection scanning * sync plex collections to db * sync plex collection items * update changelog
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexLibraries(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IPlexBackgroundServiceRequest;
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -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
|
||||
IPlexTvApiClient plexTvApiClient,
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SynchronizePlexMediaSourcesHandler> logger)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
bool LockJellyfinCollections();
|
||||
bool UnlockJellyfinCollections();
|
||||
bool AreJellyfinCollectionsLocked();
|
||||
bool LockPlexCollections();
|
||||
bool UnlockPlexCollections();
|
||||
bool ArePlexCollectionsLocked();
|
||||
bool LockPlayout(int playoutId);
|
||||
bool UnlockPlayout(int playoutId);
|
||||
bool IsPlayoutLocked(int playoutId);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
b.Property<string>("ClientIdentifier")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime?>("LastCollectionsScan")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
|
||||
+4453
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
b.Property<string>("ClientIdentifier")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("LastCollectionsScan")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
@@ -187,6 +182,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)
|
||||
{
|
||||
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
|
||||
|
||||
@@ -36,6 +36,42 @@ public interface IPlexServerApi
|
||||
[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")]
|
||||
public Task<PlexXmlVideoMetadataResponseContainer>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -37,6 +40,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; }
|
||||
|
||||
|
||||
@@ -254,6 +254,31 @@ public class PlexEtag
|
||||
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
|
||||
{
|
||||
MediaId = 0,
|
||||
@@ -270,6 +295,9 @@ public class PlexEtag
|
||||
Thumb = 20,
|
||||
Art = 21,
|
||||
|
||||
File = 30
|
||||
File = 30,
|
||||
|
||||
ChildCount = 40,
|
||||
Smart = 41 // smart collection bool
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
_ => 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
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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}";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Scanner.Application.Plex;
|
||||
|
||||
public record SynchronizePlexCollections(int PlexMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
@@ -125,6 +129,24 @@ public class Worker : BackgroundService
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
var rootCommand = new RootCommand();
|
||||
rootCommand.AddCommand(scanLocalCommand);
|
||||
rootCommand.AddCommand(scanPlexCommand);
|
||||
rootCommand.AddCommand(scanPlexCollectionsCommand);
|
||||
rootCommand.AddCommand(scanEmbyCommand);
|
||||
rootCommand.AddCommand(scanEmbyCollectionsCommand);
|
||||
rootCommand.AddCommand(scanJellyfinCommand);
|
||||
|
||||
@@ -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 @@
|
||||
<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 @@
|
||||
{
|
||||
<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 @@
|
||||
}
|
||||
<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 @@
|
||||
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
|
||||
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();
|
||||
|
||||
@@ -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 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -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
|
||||
_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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -133,6 +139,22 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -167,6 +189,35 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -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
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexCollections(mediaSourceId, false),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanJellyfinMediaSources(CancellationToken cancellationToken)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user