Browse Source

Allow Other Videos Library Type on Plex to be sync (#1766)

* Allow Other Videos Library Type on Plex to be sync

* Migrating database: Creating PlexOtherVideo table

* Using Plex Media path to create tags for OtherVideos

* missed these in the merge

* Getting PlexLibrary for Tag set on OtherVideo

* fix migrations

* set tag metadata on plex other videos

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
pull/1771/head
Sylvain 1 year ago committed by GitHub
parent
commit
36e86587ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs
  3. 7
      ErsatzTV.Core/Domain/MediaItem/PlexOtherVideo.cs
  4. 14
      ErsatzTV.Core/Interfaces/Plex/IPlexOtherVideoLibraryScanner.cs
  5. 14
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  6. 17
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerOtherVideoRepository.cs
  7. 3
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  8. 5
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  9. 8
      ErsatzTV.Core/Interfaces/Repositories/IPlexOtherVideoRepository.cs
  10. 5799
      ErsatzTV.Infrastructure.MySql/Migrations/20240702192426_Add_PlexOtherVideo.Designer.cs
  11. 43
      ErsatzTV.Infrastructure.MySql/Migrations/20240702192426_Add_PlexOtherVideo.cs
  12. 24
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  13. 5638
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240702192346_Add_PlexOtherVideo.Designer.cs
  14. 41
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240702192346_Add_PlexOtherVideo.cs
  15. 24
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  16. 10
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexOtherVideoConfiguration.cs
  17. 20
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  18. 26
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  19. 271
      ErsatzTV.Infrastructure/Data/Repositories/PlexOtherVideoRepository.cs
  20. 3
      ErsatzTV.Infrastructure/Data/TvContext.cs
  21. 4
      ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs
  22. 6
      ErsatzTV.Infrastructure/Plex/Models/PlexLocationResponse.cs
  23. 282
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  24. 12
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  25. 471
      ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
  26. 413
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  27. 4
      ErsatzTV.Scanner/Program.cs

6
CHANGELOG.md

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add support for Plex Other Video libraries
- These libraries will now appear as ETV Other Video libraries
- Items in these libraries will have tag metadata added from folders just like local Other Video libraries
- Thanks @raknam for adding this feature!
### Changed
- Remove some unnecessary API calls related to media server scanning and paging

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

@ -84,10 +84,13 @@ public class @@ -84,10 +84,13 @@ public class
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.Key != l.Key) && toRemove.All(r => r.Key != l.Key)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
toRemove,
toUpdate);
if (ids.Count != 0)
{
await _searchIndex.RemoveItems(ids);

7
ErsatzTV.Core/Domain/MediaItem/PlexOtherVideo.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public class PlexOtherVideo : OtherVideo
{
public string Key { get; set; }
public string Etag { get; set; }
}

14
ErsatzTV.Core/Interfaces/Plex/IPlexOtherVideoLibraryScanner.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexOtherVideoLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
bool deepScan,
CancellationToken cancellationToken);
}

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

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Plex;
@ -18,6 +18,11 @@ public interface IPlexServerApiClient @@ -18,6 +18,11 @@ public interface IPlexServerApiClient
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token);
IAsyncEnumerable<Tuple<PlexShow, int>> GetShowLibraryContents(
PlexLibrary library,
PlexConnection connection,
@ -47,6 +52,13 @@ public interface IPlexServerApiClient @@ -47,6 +52,13 @@ public interface IPlexServerApiClient
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, Tuple<OtherVideoMetadata, MediaVersion>>> GetOtherVideoMetadataAndStatistics(
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library);
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
int plexMediaSourceId,
string key,

17
ErsatzTV.Core/Interfaces/Repositories/IMediaServerOtherVideoRepository.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaServerOtherVideoRepository<in TLibrary, TOtherVideo, TEtag> where TLibrary : Library
where TOtherVideo : OtherVideo
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingOtherVideos(TLibrary library);
Task<Option<int>> FlagNormal(TLibrary library, TOtherVideo otherVideo);
Task<Option<int>> FlagUnavailable(TLibrary library, TOtherVideo otherVideo);
Task<Option<int>> FlagRemoteOnly(TLibrary library, TOtherVideo otherVideo);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);
Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> GetOrAdd(TLibrary library, TOtherVideo item, bool deepScan);
Task<Unit> SetEtag(TOtherVideo otherVideo, string etag);
}

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

@ -20,7 +20,8 @@ public interface IMediaSourceRepository @@ -20,7 +20,8 @@ public interface IMediaSourceRepository
Task<List<int>> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete);
List<PlexLibrary> toDelete,
List<PlexLibrary> toUpdate);
Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,

5
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
@ -28,11 +28,14 @@ public interface IMetadataRepository @@ -28,11 +28,14 @@ public interface IMetadataRepository
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(OtherVideoMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsExternal(ShowMetadata metadata);
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
Task<Unit> MarkAsExternal(MovieMetadata metadata);
Task<Unit> MarkAsExternal(OtherVideoMetadata metadata);
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
Task<Unit> SetContentRating(OtherVideoMetadata metadata, string contentRating);
[SuppressMessage("Naming", "CA1720:Identifier contains type name")]
Task<bool> RemoveGuid(MetadataGuid guid);

8
ErsatzTV.Core/Interfaces/Repositories/IPlexOtherVideoRepository.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexOtherVideoRepository : IMediaServerOtherVideoRepository<PlexLibrary, PlexOtherVideo, PlexItemEtag>
{
}

5799
ErsatzTV.Infrastructure.MySql/Migrations/20240702192426_Add_PlexOtherVideo.Designer.cs generated

File diff suppressed because it is too large Load Diff

43
ErsatzTV.Infrastructure.MySql/Migrations/20240702192426_Add_PlexOtherVideo.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlexOtherVideo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlexOtherVideo",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Key = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Etag = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_PlexOtherVideo", x => x.Id);
table.ForeignKey(
name: "FK_PlexOtherVideo_OtherVideo_Id",
column: x => x.Id,
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlexOtherVideo");
}
}
}

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

@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -3484,6 +3484,19 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3484,6 +3484,19 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("PlexMovie", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.OtherVideo");
b.Property<string>("Etag")
.HasColumnType("longtext");
b.Property<string>("Key")
.HasColumnType("longtext");
b.ToTable("PlexOtherVideo", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Season");
@ -5288,6 +5301,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5288,6 +5301,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexOtherVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Season", null)

5638
ErsatzTV.Infrastructure.Sqlite/Migrations/20240702192346_Add_PlexOtherVideo.Designer.cs generated

File diff suppressed because it is too large Load Diff

41
ErsatzTV.Infrastructure.Sqlite/Migrations/20240702192346_Add_PlexOtherVideo.cs

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlexOtherVideo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlexOtherVideo",
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)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexOtherVideo", x => x.Id);
table.ForeignKey(
name: "FK_PlexOtherVideo_OtherVideo_Id",
column: x => x.Id,
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlexOtherVideo");
}
}
}

24
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", "8.0.4");
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -3323,6 +3323,19 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3323,6 +3323,19 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("PlexMovie", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.OtherVideo");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.ToTable("PlexOtherVideo", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Season");
@ -5127,6 +5140,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5127,6 +5140,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexOtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexOtherVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Season", null)

10
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexOtherVideoConfiguration.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 PlexOtherVideoConfiguration : IEntityTypeConfiguration<PlexOtherVideo>
{
public void Configure(EntityTypeBuilder<PlexOtherVideo> builder) => builder.ToTable("PlexOtherVideo");
}

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

@ -141,7 +141,8 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -141,7 +141,8 @@ public class MediaSourceRepository : IMediaSourceRepository
public async Task<List<int>> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete)
List<PlexLibrary> toDelete,
List<PlexLibrary> toUpdate)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -166,6 +167,23 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -166,6 +167,23 @@ public class MediaSourceRepository : IMediaSourceRepository
dbContext.PlexLibraries.Remove(delete);
}
// update library path (for other video metadata)
foreach (PlexLibrary incoming in toUpdate)
{
Option<PlexLibrary> maybeExisting = await dbContext.PlexLibraries
.Include(l => l.Paths)
.SelectOneAsync(l => l.Key, l => l.Key == incoming.Key);
foreach (LibraryPath existing in maybeExisting.Map(l => l.Paths.HeadOrNone()))
{
foreach (LibraryPath path in incoming.Paths.HeadOrNone())
{
existing.Path = path.Path;
}
}
}
await dbContext.SaveChangesAsync();
return deletedMediaIds;

26
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using Dapper;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
@ -427,6 +427,30 @@ public class MetadataRepository : IMetadataRepository @@ -427,6 +427,30 @@ public class MetadataRepository : IMetadataRepository
new { metadata.Id, ContentRating = contentRating }).ToUnit();
}
public async Task<Unit> MarkAsUpdated(OtherVideoMetadata metadata, DateTime dateUpdated)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
}
public async Task<Unit> MarkAsExternal(OtherVideoMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET MetadataKind = @Kind WHERE Id = @Id",
new { metadata.Id, Kind = (int)MetadataKind.External }).ToUnit();
}
public async Task<Unit> SetContentRating(OtherVideoMetadata metadata, string contentRating)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE OtherVideoMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
}
public async Task<bool> RemoveGuid(MetadataGuid guid)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

271
ErsatzTV.Infrastructure/Data/Repositories/PlexOtherVideoRepository.cs

@ -0,0 +1,271 @@ @@ -0,0 +1,271 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class PlexOtherVideoRepository : IPlexOtherVideoRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<PlexOtherVideoRepository> _logger;
public PlexOtherVideoRepository(IDbContextFactory<TvContext> dbContextFactory, ILogger<PlexOtherVideoRepository> logger)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<List<PlexItemEtag>> GetExistingOtherVideos(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT `Key`, Etag, MI.State FROM PlexOtherVideo
INNER JOIN OtherVideo O on PlexOtherVideo.Id = O.Id
INNER JOIN MediaItem MI on O.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<Option<int>> FlagNormal(PlexLibrary library, PlexOtherVideo otherVideo)
{
if (otherVideo.State is MediaItemState.Normal)
{
return Option<int>.None;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
otherVideo.State = MediaItemState.Normal;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexOtherVideo.Id FROM PlexOtherVideo
INNER JOIN MediaItem MI ON MI.Id = PlexOtherVideo.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexOtherVideo.Key = @Key",
new { LibraryId = library.Id, otherVideo.Key });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 0 WHERE Id = @Id AND State != 0",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexOtherVideo otherVideo)
{
if (otherVideo.State is MediaItemState.Unavailable)
{
return Option<int>.None;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
otherVideo.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexOtherVideo.Id FROM PlexOtherVideo
INNER JOIN MediaItem MI ON MI.Id = PlexOtherVideo.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexOtherVideo.Key = @Key",
new { LibraryId = library.Id, otherVideo.Key });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 2 WHERE Id = @Id AND State != 2",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<Option<int>> FlagRemoteOnly(PlexLibrary library, PlexOtherVideo otherVideo)
{
if (otherVideo.State is MediaItemState.RemoteOnly)
{
return Option<int>.None;
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
otherVideo.State = MediaItemState.RemoteOnly;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexOtherVideo.Id FROM PlexOtherVideo
INNER JOIN MediaItem MI ON MI.Id = PlexOtherVideo.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexOtherVideo.Key = @Key",
new { LibraryId = library.Id, otherVideo.Key });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 3 WHERE Id = @Id AND State != 3",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> movieItemIds)
{
if (movieItemIds.Count == 0)
{
return [];
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN PlexOtherVideo ON PlexOtherVideo.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE PlexOtherVideo.Key IN @OtherVideoKeys",
new { LibraryId = library.Id, OtherVideoKeys = movieItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids AND State != 1",
new { Ids = ids });
return ids;
}
public async Task<Either<BaseError, MediaItemScanResult<PlexOtherVideo>>> GetOrAdd(
PlexLibrary library,
PlexOtherVideo item,
bool deepScan)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexOtherVideo> maybeExisting = await dbContext.PlexOtherVideos
.AsNoTracking()
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(i => i.Key, i => i.Key == item.Key);
foreach (PlexOtherVideo plexOtherVideo in maybeExisting)
{
var result = new MediaItemScanResult<PlexOtherVideo>(plexOtherVideo) { IsAdded = false };
if (plexOtherVideo.Etag != item.Etag || deepScan)
{
await UpdateOtherVideoPath(dbContext, plexOtherVideo, item);
result.IsUpdated = true;
}
return result;
}
return await AddOtherVideo(dbContext, library, item);
}
public async Task<Unit> SetEtag(PlexOtherVideo otherVideo, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexOtherVideo SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, otherVideo.Id }).Map(_ => Unit.Default);
}
private async Task<Either<BaseError, MediaItemScanResult<PlexOtherVideo>>> AddOtherVideo(
TvContext dbContext,
PlexLibrary library,
PlexOtherVideo item)
{
try
{
if (await MediaItemRepository.MediaFileAlreadyExists(item, library.Paths.Head().Id, dbContext, _logger))
{
return new MediaFileAlreadyExists();
}
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexOtherVideos.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexOtherVideo>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private static async Task UpdateOtherVideoPath(TvContext dbContext, PlexOtherVideo existing, PlexOtherVideo incoming)
{
// library path is used for search indexing later
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaVersion SET Name = @Name, DateAdded = @DateAdded WHERE Id = @Id",
new { version.Name, version.DateAdded, version.Id });
// media file
if (version.MediaFiles.Head() is PlexMediaFile file &&
incomingVersion.MediaFiles.Head() is PlexMediaFile incomingFile)
{
file.Path = incomingFile.Path;
file.Key = incomingFile.Key;
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaFile SET Path = @Path WHERE Id = @Id",
new { file.Path, file.Id });
await dbContext.Connection.ExecuteAsync(
@"UPDATE PlexMediaFile SET `Key` = @Key WHERE Id = @Id",
new { file.Key, file.Id });
}
}
}

3
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Data;
using System.Data;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
@ -63,6 +63,7 @@ public class TvContext : DbContext @@ -63,6 +63,7 @@ public class TvContext : DbContext
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<PlexOtherVideo> PlexOtherVideos { get; set; }
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }

4
ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
namespace ErsatzTV.Infrastructure.Plex.Models;
namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexLibraryResponse
{
@ -8,4 +8,6 @@ public class PlexLibraryResponse @@ -8,4 +8,6 @@ public class PlexLibraryResponse
public string Agent { get; set; }
public int Hidden { get; set; }
public string Uuid { get; set; }
public string Language { get; set; }
public List<PlexLocationResponse> Location { get; set; }
}

6
ErsatzTV.Infrastructure/Plex/Models/PlexLocationResponse.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexLocationResponse
{
public int Id { get; set; }
public string Path { get; set; }
}

282
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Xml.Serialization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@ -7,6 +7,7 @@ using ErsatzTV.Core.Metadata; @@ -7,6 +7,7 @@ using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Refit;
namespace ErsatzTV.Infrastructure.Plex;
@ -50,15 +51,12 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -50,15 +51,12 @@ public class PlexServerApiClient : IPlexServerApiClient
});
List<PlexLibraryResponse> directory =
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
return directory
// .Filter(l => l.Hidden == 0)
List<PlexLibrary> response = directory
.Filter(l => l.Type.ToLowerInvariant() is "movie" or "show")
.Filter(
l => l.Type.ToLowerInvariant() is not "movie" ||
(l.Agent ?? string.Empty).ToLowerInvariant() is not "com.plexapp.agents.none")
.Map(Project)
.Somes()
.ToList();
return response;
}
catch (Exception ex)
{
@ -110,6 +108,29 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -110,6 +108,29 @@ public class PlexServerApiClient : IPlexServerApiClient
return GetPagedLibraryContents(connection, CountItems, GetItems);
}
public IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryContents(
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
{
return service.GetLibrarySection(library.Key, token.AuthToken);
}
Task<IEnumerable<PlexOtherVideo>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
{
return jsonService
.GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken)
.Map(
r => r.MediaContainer.Metadata.Filter(
m => m.Media.Count > 0 && m.Media.Any(media => media.Part.Count > 0)))
.Map(list => list.Map(metadata => ProjectToOtherVideo(metadata, library.MediaSourceId, library)));
}
return GetPagedLibraryContents(connection, CountItems, GetItems);
}
public IAsyncEnumerable<Tuple<PlexSeason, int>> GetShowSeasons(
PlexLibrary library,
PlexShow show,
@ -209,6 +230,40 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -209,6 +230,40 @@ public class PlexServerApiClient : IPlexServerApiClient
}
}
public async Task<Either<BaseError, Tuple<OtherVideoMetadata, MediaVersion>>> GetOtherVideoMetadataAndStatistics(
int plexMediaSourceId,
string key,
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library)
{
try
{
IPlexServerApi service = XmlServiceFor(connection.Uri);
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(
m => m.Metadata.Media.Count > 0 && m.Metadata.Media.Any(media => media.Part.Count > 0)));
return maybeResponse.Match(
response =>
{
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<OtherVideoMetadata, MediaVersion>>>(
version => Tuple(
ProjectToOtherVideoMetadata(version, response.Metadata, plexMediaSourceId, library),
version),
() => BaseError.New("Unable to locate metadata"));
},
() => BaseError.New("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
int plexMediaSourceId,
string key,
@ -336,8 +391,18 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -336,8 +391,18 @@ public class PlexServerApiClient : IPlexServerApiClient
});
}
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
response.Type switch
private static Option<PlexLibrary> Project(PlexLibraryResponse response)
{
List<LibraryPath> paths =
[
new LibraryPath
{
Path = JsonConvert.SerializeObject(
new LibraryPaths { Paths = response.Location.Map(l => l.Path).ToList() })
}
];
return response.Type switch
{
"show" => new PlexLibrary
{
@ -345,19 +410,22 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -345,19 +410,22 @@ public class PlexServerApiClient : IPlexServerApiClient
Name = response.Title,
MediaKind = LibraryMediaKind.Shows,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
Paths = paths
},
"movie" => new PlexLibrary
{
Key = response.Key,
Name = response.Title,
MediaKind = LibraryMediaKind.Movies,
MediaKind = response.Agent == "com.plexapp.agents.none" && response.Language == "xn"
? LibraryMediaKind.OtherVideos
: LibraryMediaKind.Movies,
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
Paths = paths
},
// TODO: "artist" for music libraries
_ => None
};
}
private Option<PlexCollection> ProjectToCollection(
PlexMediaSource plexMediaSource,
@ -994,6 +1062,192 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -994,6 +1062,192 @@ public class PlexServerApiClient : IPlexServerApiClient
EndTime = TimeSpan.FromMilliseconds(chapter.EndTimeOffset)
};
private PlexOtherVideo ProjectToOtherVideo(PlexMetadataResponse response, int mediaSourceId, PlexLibrary library)
{
PlexMediaResponse<PlexPartResponse> media = response.Media
.Filter(media => media.Part.Count != 0)
.MaxBy(media => media.Id);
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
// specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
},
Streams = new List<MediaStream>()
};
OtherVideoMetadata metadata = ProjectToOtherVideoMetadata(version, response, mediaSourceId, library);
var otherVideo = new PlexOtherVideo
{
Etag = _plexEtag.ForMovie(response),
Key = response.Key,
OtherVideoMetadata = new List<OtherVideoMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version },
TraktListItems = new List<TraktListItem>()
};
return otherVideo;
}
private OtherVideoMetadata ProjectToOtherVideoMetadata(MediaVersion version, PlexMetadataResponse response, int mediaSourceId, PlexLibrary library)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new OtherVideoMetadata
{
MetadataKind = MetadataKind.External,
Title = response.Title,
SortTitle = SortTitle.GetSortTitle(response.Title),
Plot = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
ContentRating = response.ContentRating,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList(),
Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(),
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(),
Subtitles = new List<Subtitle>()
};
var subtitleStreams = version.Streams
.Filter(s => s.MediaStreamKind is MediaStreamKind.Subtitle or MediaStreamKind.ExternalSubtitle)
.ToList();
metadata.Subtitles.AddRange(subtitleStreams.Map(Subtitle.FromMediaStream));
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
PlexMediaResponse<PlexXmlPartResponse> media = xml.Media
.Filter(media => media.Part.Count != 0)
.MaxBy(media => media.Id);
PlexXmlPartResponse part = media.Part.Head();
string folder = Path.GetDirectoryName(part.File);
if (!string.IsNullOrWhiteSpace(folder))
{
IEnumerable<string> libraryPaths = library.Paths
.HeadOrNone()
.Map(p => p.Path)
.Map(JsonConvert.DeserializeObject<LibraryPaths>)
.Map(lp => lp.Paths)
.Flatten();
// check each library path from plex
foreach (string libraryPath in libraryPaths)
{
// if the media file belongs to this library path
if (folder.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
{
// try to get a parent directory of the library path
string parent = Optional(Directory.GetParent(libraryPath)).Match(
di => di.FullName,
() => libraryPath);
// get all folders between parent and media file
string diff = Path.GetRelativePath(parent, folder);
// each folder becomes a tag
IEnumerable<Tag> tags = diff.Split(Path.DirectorySeparatorChar)
.Map(t => new Tag { Name = t });
metadata.Tags.AddRange(tags);
break;
}
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) });
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
if (!string.IsNullOrWhiteSpace(response.Art))
{
var path = $"plex/{mediaSourceId}{response.Art}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.FanArt,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
return metadata;
}
private Option<string> NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show", StringComparison.OrdinalIgnoreCase) ||
@ -1037,4 +1291,10 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -1037,4 +1291,10 @@ public class PlexServerApiClient : IPlexServerApiClient
return None;
}
private sealed class LibraryPaths
{
[JsonProperty("paths")]
public List<string> Paths { get; set; } = [];
}
}

12
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@ -16,6 +16,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -16,6 +16,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
private readonly IPlexOtherVideoLibraryScanner _plexOtherVideoLibraryScanner;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
@ -25,6 +26,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -25,6 +26,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
IConfigElementRepository configElementRepository,
IPlexSecretStore plexSecretStore,
IPlexMovieLibraryScanner plexMovieLibraryScanner,
IPlexOtherVideoLibraryScanner plexOtherVideoLibraryScanner,
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
ILibraryRepository libraryRepository,
ILogger<SynchronizePlexLibraryByIdHandler> logger)
@ -34,6 +36,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -34,6 +36,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
_configElementRepository = configElementRepository;
_plexSecretStore = plexSecretStore;
_plexMovieLibraryScanner = plexMovieLibraryScanner;
_plexOtherVideoLibraryScanner = plexOtherVideoLibraryScanner;
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
_libraryRepository = libraryRepository;
_logger = logger;
@ -66,6 +69,13 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -66,6 +69,13 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
parameters.Library,
parameters.DeepScan,
cancellationToken),
LibraryMediaKind.OtherVideos =>
await _plexOtherVideoLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library,
parameters.DeepScan,
cancellationToken),
LibraryMediaKind.Shows =>
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,

471
ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs

@ -0,0 +1,471 @@ @@ -0,0 +1,471 @@
using System.Collections.Immutable;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Metadata;
public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters, TLibrary, TOtherVideo, TEtag>
where TConnectionParameters : MediaServerConnectionParameters
where TLibrary : Library
where TOtherVideo : OtherVideo
where TEtag : MediaServerItemEtag
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
protected MediaServerOtherVideoLibraryScanner(
ILocalFileSystem localFileSystem,
IMetadataRepository metadataRepository,
IMediator mediator,
ILogger logger)
{
_localFileSystem = localFileSystem;
_metadataRepository = metadataRepository;
_mediator = mediator;
_logger = logger;
}
protected virtual bool ServerSupportsRemoteStreaming => false;
protected virtual bool ServerReturnsStatisticsWithMetadata => false;
protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerOtherVideoRepository<TLibrary, TOtherVideo, TEtag> otherVideoRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TOtherVideo, string> getLocalPath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
return await ScanLibrary(
otherVideoRepository,
connectionParameters,
library,
getLocalPath,
GetOtherVideoLibraryItems(connectionParameters, library),
deepScan,
cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerOtherVideoRepository<TLibrary, TOtherVideo, TEtag> otherVideoRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TOtherVideo, string> getLocalPath,
IAsyncEnumerable<Tuple<TOtherVideo, int>> otherVideoEntries,
bool deepScan,
CancellationToken cancellationToken)
{
var incomingItemIds = new List<string>();
IReadOnlyDictionary<string, TEtag> existingOtherVideos = (await otherVideoRepository.GetExistingOtherVideos(library))
.ToImmutableDictionary(e => e.MediaServerItemId, e => e);
await foreach ((TOtherVideo incoming, int totalOtherVideoCount) in otherVideoEntries.WithCancellation(cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
incomingItemIds.Add(MediaServerItemId(incoming));
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalOtherVideoCount, 0, 1);
await _mediator.Publish(
new ScannerProgressUpdate(
library.Id,
library.Name,
percentCompletion,
Array.Empty<int>(),
Array.Empty<int>()),
cancellationToken);
string localPath = getLocalPath(incoming);
if (await ShouldScanItem(otherVideoRepository, library, existingOtherVideos, incoming, localPath, deepScan) == false)
{
continue;
}
Either<BaseError, MediaItemScanResult<TOtherVideo>> maybeOtherVideo;
if (ServerReturnsStatisticsWithMetadata)
{
maybeOtherVideo = await otherVideoRepository
.GetOrAdd(library, incoming, deepScan)
.MapT(
result =>
{
result.LocalPath = localPath;
return result;
})
.BindT(
existing => UpdateMetadataAndStatistics(
connectionParameters,
library,
existing,
incoming,
deepScan));
}
else
{
maybeOtherVideo = await otherVideoRepository
.GetOrAdd(library, incoming, deepScan)
.MapT(
result =>
{
result.LocalPath = localPath;
return result;
})
.BindT(
existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan, None))
.BindT(
existing => UpdateStatistics(
connectionParameters,
library,
existing,
incoming,
deepScan,
None))
.BindT(UpdateSubtitles);
}
if (maybeOtherVideo.IsLeft)
{
foreach (BaseError error in maybeOtherVideo.LeftToSeq())
{
_logger.LogWarning(
"Error processing other video {Title}: {Error}",
incoming.OtherVideoMetadata.Head().Title,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<TOtherVideo> result in maybeOtherVideo.RightToSeq())
{
await otherVideoRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (_localFileSystem.FileExists(result.LocalPath))
{
Option<int> flagResult = await otherVideoRepository.FlagNormal(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
else if (ServerSupportsRemoteStreaming)
{
Option<int> flagResult = await otherVideoRepository.FlagRemoteOnly(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
else
{
Option<int> flagResult = await otherVideoRepository.FlagUnavailable(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
if (result.IsAdded || result.IsUpdated)
{
await _mediator.Publish(
new ScannerProgressUpdate(
library.Id,
null,
null,
new[] { result.Item.Id },
Array.Empty<int>()),
cancellationToken);
}
}
}
// trash OtherVideo that are no longer present on the media server
var fileNotFoundItemIds = existingOtherVideos.Keys.Except(incomingItemIds).ToList();
List<int> ids = await otherVideoRepository.FlagFileNotFound(library, fileNotFoundItemIds);
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, ids.ToArray(), Array.Empty<int>()),
cancellationToken);
await _mediator.Publish(
new ScannerProgressUpdate(
library.Id,
library.Name,
0,
Array.Empty<int>(),
Array.Empty<int>()),
cancellationToken);
return Unit.Default;
}
protected abstract string MediaServerItemId(TOtherVideo otherVideo);
protected abstract string MediaServerEtag(TOtherVideo otherVideo);
protected abstract IAsyncEnumerable<Tuple<TOtherVideo, int>> GetOtherVideoLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract Task<Option<OtherVideoMetadata>> GetFullMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming,
bool deepScan);
protected virtual Task<Option<MediaVersion>> GetMediaServerStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming) => Task.FromResult(Option<MediaVersion>.None);
protected abstract Task<Option<Tuple<OtherVideoMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming);
protected abstract Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateMetadata(
MediaItemScanResult<TOtherVideo> result,
OtherVideoMetadata fullMetadata);
private async Task<bool> ShouldScanItem(
IMediaServerOtherVideoRepository<TLibrary, TOtherVideo, TEtag> otherVideoRepository,
TLibrary library,
IReadOnlyDictionary<string, TEtag> existingOtherVideos,
TOtherVideo incoming,
string localPath,
bool deepScan)
{
// deep scan will always pull every OtherVideo
if (deepScan)
{
return true;
}
string existingEtag = string.Empty;
MediaItemState existingState = MediaItemState.Normal;
if (existingOtherVideos.TryGetValue(MediaServerItemId(incoming), out TEtag? existingEntry))
{
existingEtag = existingEntry.Etag;
existingState = existingEntry.State;
}
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
existingEtag == MediaServerEtag(incoming))
{
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming)
{
return false;
}
}
else if (existingEtag == MediaServerEtag(incoming))
{
// item is unchanged, but file does not exist
// don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath))
{
if (ServerSupportsRemoteStreaming)
{
if (existingState is not MediaItemState.RemoteOnly)
{
foreach (int id in await otherVideoRepository.FlagRemoteOnly(library, incoming))
{
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
}
}
else
{
if (existingState is not MediaItemState.Unavailable)
{
foreach (int id in await otherVideoRepository.FlagUnavailable(library, incoming))
{
await _mediator.Publish(
new ScannerProgressUpdate(library.Id, null, null, new[] { id }, Array.Empty<int>()),
CancellationToken.None);
}
}
}
}
return false;
}
if (existingEntry is null)
{
_logger.LogDebug("INSERT: new other video {OtherVideo}", incoming.OtherVideoMetadata.Head().Title);
}
else
{
_logger.LogDebug("UPDATE: Etag has changed for other video {OtherVideo}", incoming.OtherVideoMetadata.Head().Title);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateMetadataAndStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming,
bool deepScan)
{
Option<Tuple<OtherVideoMetadata, MediaVersion>> maybeMetadataAndStatistics = await GetFullMetadataAndStatistics(
connectionParameters,
library,
result,
incoming);
foreach ((OtherVideoMetadata fullMetadata, MediaVersion mediaVersion) in maybeMetadataAndStatistics)
{
Either<BaseError, MediaItemScanResult<TOtherVideo>> metadataResult = await UpdateMetadata(
connectionParameters,
library,
result,
incoming,
deepScan,
fullMetadata);
foreach (BaseError error in metadataResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TOtherVideo> r in metadataResult.RightToSeq())
{
result = r;
}
Either<BaseError, MediaItemScanResult<TOtherVideo>> statisticsResult = await UpdateStatistics(
connectionParameters,
library,
result,
incoming,
deepScan,
mediaVersion);
foreach (BaseError error in statisticsResult.LeftToSeq())
{
return error;
}
foreach (MediaItemScanResult<TOtherVideo> r in metadataResult.RightToSeq())
{
result = r;
}
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming,
bool deepScan,
Option<OtherVideoMetadata> maybeFullMetadata)
{
if (maybeFullMetadata.IsNone)
{
maybeFullMetadata = await GetFullMetadata(connectionParameters, library, result, incoming, deepScan);
}
foreach (OtherVideoMetadata fullMetadata in maybeFullMetadata)
{
// TODO: move some of this code into this scanner
// will have to merge JF, Emby, Plex logic
return await UpdateMetadata(result, fullMetadata);
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateStatistics(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TOtherVideo> result,
TOtherVideo incoming,
bool deepScan,
Option<MediaVersion> maybeMediaVersion)
{
TOtherVideo existing = result.Item;
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0)
{
if (maybeMediaVersion.IsNone)
{
maybeMediaVersion = await GetMediaServerStatistics(
connectionParameters,
library,
result,
incoming);
}
foreach (MediaVersion mediaVersion in maybeMediaVersion)
{
if (await _metadataRepository.UpdateStatistics(result.Item, mediaVersion))
{
result.IsUpdated = true;
}
}
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TOtherVideo>>> UpdateSubtitles(
MediaItemScanResult<TOtherVideo> existing)
{
try
{
MediaVersion version = existing.Item.GetHeadVersion();
Option<OtherVideoMetadata> maybeMetadata = existing.Item.OtherVideoMetadata.HeadOrNone();
foreach (OtherVideoMetadata metadata in maybeMetadata)
{
List<Subtitle> subtitles = version.Streams
.Filter(s => s.MediaStreamKind is MediaStreamKind.Subtitle or MediaStreamKind.ExternalSubtitle)
.Map(Subtitle.FromMediaStream)
.ToList();
if (await _metadataRepository.UpdateSubtitles(metadata, subtitles))
{
return existing;
}
}
return BaseError.New("Failed to update media server subtitles");
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

413
ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs

@ -0,0 +1,413 @@ @@ -0,0 +1,413 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Scanner.Core.Metadata;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Plex;
public class PlexOtherVideoLibraryScanner :
MediaServerOtherVideoLibraryScanner<PlexConnectionParameters, PlexLibrary, PlexOtherVideo, PlexItemEtag>,
IPlexOtherVideoLibraryScanner
{
private readonly ILogger<PlexOtherVideoLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMetadataRepository _metadataRepository;
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly IPlexOtherVideoRepository _plexOtherVideoRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
public PlexOtherVideoLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IOtherVideoRepository otherVideoRepository,
IMetadataRepository metadataRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexOtherVideoRepository plexOtherVideoRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexOtherVideoLibraryScanner> logger)
: base(
localFileSystem,
metadataRepository,
mediator,
logger)
{
_plexServerApiClient = plexServerApiClient;
_otherVideoRepository = otherVideoRepository;
_metadataRepository = metadataRepository;
_mediaSourceRepository = mediaSourceRepository;
_plexOtherVideoRepository = plexOtherVideoRepository;
_plexPathReplacementService = plexPathReplacementService;
_logger = logger;
}
protected override bool ServerSupportsRemoteStreaming => true;
protected override bool ServerReturnsStatisticsWithMetadata => true;
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
bool deepScan,
CancellationToken cancellationToken)
{
List<PlexPathReplacement> pathReplacements =
await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId);
string GetLocalPath(PlexOtherVideo otherVideo)
{
return _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
otherVideo.GetHeadVersion().MediaFiles.Head().Path,
false);
}
return await ScanLibrary(
_plexOtherVideoRepository,
new PlexConnectionParameters(connection, token),
library,
GetLocalPath,
deepScan,
cancellationToken);
}
protected override string MediaServerItemId(PlexOtherVideo otherVideo) => otherVideo.Key;
protected override string MediaServerEtag(PlexOtherVideo otherVideo) => otherVideo.Etag;
protected override IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetOtherVideoLibraryContents(
library,
connectionParameters.Connection,
connectionParameters.Token);
// this shouldn't be called anymore
protected override Task<Option<OtherVideoMetadata>> GetFullMetadata(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexOtherVideo> result,
PlexOtherVideo incoming,
bool deepScan)
{
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
{
throw new NotSupportedException("This shouldn't happen anymore");
}
return Task.FromResult<Option<OtherVideoMetadata>>(None);
}
// this shouldn't be called anymore
protected override async Task<Option<MediaVersion>> GetMediaServerStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexOtherVideo> result,
PlexOtherVideo incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Statistics", result.LocalPath);
Either<BaseError, MediaVersion> maybeVersion =
await _plexServerApiClient.GetOtherVideoMetadataAndStatistics(
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token,
library)
.MapT(tuple => tuple.Item2); // drop the metadata part
foreach (BaseError error in maybeVersion.LeftToSeq())
{
_logger.LogWarning("Failed to get otherVideo statistics from Plex: {Error}", error.ToString());
}
return maybeVersion.ToOption();
}
protected override async Task<Option<Tuple<OtherVideoMetadata, MediaVersion>>> GetFullMetadataAndStatistics(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexOtherVideo> result,
PlexOtherVideo incoming)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Plex Metadata and Statistics", result.LocalPath);
Either<BaseError, Tuple<OtherVideoMetadata, MediaVersion>> maybeResult =
await _plexServerApiClient.GetOtherVideoMetadataAndStatistics(
library.MediaSourceId,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token,
library);
foreach (BaseError error in maybeResult.LeftToSeq())
{
_logger.LogWarning("Failed to get OtherVideo metadata and statistics from Plex: {Error}", error.ToString());
}
return maybeResult.ToOption();
}
protected override async Task<Either<BaseError, MediaItemScanResult<PlexOtherVideo>>> UpdateMetadata(
MediaItemScanResult<PlexOtherVideo> result,
OtherVideoMetadata fullMetadata)
{
PlexOtherVideo existing = result.Item;
OtherVideoMetadata existingMetadata = existing.OtherVideoMetadata.Head();
if (existingMetadata.MetadataKind != MetadataKind.External)
{
existingMetadata.MetadataKind = MetadataKind.External;
await _metadataRepository.MarkAsExternal(existingMetadata);
}
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
{
existingMetadata.ContentRating = fullMetadata.ContentRating;
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
result.IsUpdated = true;
}
foreach (Genre genre in existingMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
}
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _otherVideoRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _otherVideoRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
foreach (Actor actor in existingMetadata.Actors
.Filter(
a => fullMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
result.IsUpdated = true;
}
}
foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Add(actor);
if (await _otherVideoRepository.AddActor(existingMetadata, actor))
{
result.IsUpdated = true;
}
}
foreach (Director director in existingMetadata.Directors
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
result.IsUpdated = true;
}
}
foreach (Director director in fullMetadata.Directors
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Directors.Add(director);
if (await _otherVideoRepository.AddDirector(existingMetadata, director))
{
result.IsUpdated = true;
}
}
foreach (Writer writer in existingMetadata.Writers
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
result.IsUpdated = true;
}
}
foreach (Writer writer in fullMetadata.Writers
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Writers.Add(writer);
if (await _otherVideoRepository.AddWriter(existingMetadata, writer))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
if (await _otherVideoRepository.AddTag(existingMetadata, tag))
{
result.IsUpdated = true;
}
}
if (await _metadataRepository.UpdateSubtitles(existingMetadata, fullMetadata.Subtitles))
{
result.IsUpdated = true;
}
/*
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
// Not existing on IOtherVideoRepository
if (await _otherVideoRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
}
*/
bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);
if (poster || fanArt)
{
result.IsUpdated = true;
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
return result;
}
private async Task<bool> UpdateArtworkIfNeeded(
ErsatzTV.Core.Domain.Metadata existingMetadata,
ErsatzTV.Core.Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
{
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
if (maybeIncomingArtwork.IsNone)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
}
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
{
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
if (maybeExistingArtwork.IsNone)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
}
foreach (Artwork existingArtwork in maybeExistingArtwork)
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
}
}
return true;
}
return false;
}
}

4
ErsatzTV.Scanner/Program.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using Bugsnag;
using Bugsnag;
using Bugsnag.Payload;
using Dapper;
using ErsatzTV.Core;
@ -198,11 +198,13 @@ public class Program @@ -198,11 +198,13 @@ public class Program
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
services.AddScoped<IPlexOtherVideoRepository, PlexOtherVideoRepository>();
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<PlexEtag>();

Loading…
Cancel
Save