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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class PlexOtherVideo : OtherVideo
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
.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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
.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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models;
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models;
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models;
|
||||
public class PlexLocationResponse
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
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
|
||||
});
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private sealed class LibraryPaths
|
||||
{
|
||||
[JsonProperty("paths")]
|
||||
public List<string> Paths { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
IConfigElementRepository configElementRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||
IPlexOtherVideoLibraryScanner plexOtherVideoLibraryScanner,
|
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||
@@ -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
|
||||
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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bugsnag;
|
||||
using Bugsnag;
|
||||
using Bugsnag.Payload;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user