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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Changed
|
||||||
- Remove some unnecessary API calls related to media server scanning and paging
|
- 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 existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).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 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(
|
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||||
connectionParameters.PlexMediaSource.Id,
|
connectionParameters.PlexMediaSource.Id,
|
||||||
toAdd,
|
toAdd,
|
||||||
toRemove);
|
toRemove,
|
||||||
|
toUpdate);
|
||||||
if (ids.Count != 0)
|
if (ids.Count != 0)
|
||||||
{
|
{
|
||||||
await _searchIndex.RemoveItems(ids);
|
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;
|
using ErsatzTV.Core.Plex;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Plex;
|
namespace ErsatzTV.Core.Interfaces.Plex;
|
||||||
@@ -18,6 +18,11 @@ public interface IPlexServerApiClient
|
|||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
PlexServerAuthToken token);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Tuple<PlexOtherVideo, int>> GetOtherVideoLibraryContents(
|
||||||
|
PlexLibrary library,
|
||||||
|
PlexConnection connection,
|
||||||
|
PlexServerAuthToken token);
|
||||||
|
|
||||||
IAsyncEnumerable<Tuple<PlexShow, int>> GetShowLibraryContents(
|
IAsyncEnumerable<Tuple<PlexShow, int>> GetShowLibraryContents(
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
@@ -47,6 +52,13 @@ public interface IPlexServerApiClient
|
|||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token);
|
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(
|
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
|
||||||
int plexMediaSourceId,
|
int plexMediaSourceId,
|
||||||
string key,
|
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(
|
Task<List<int>> UpdateLibraries(
|
||||||
int plexMediaSourceId,
|
int plexMediaSourceId,
|
||||||
List<PlexLibrary> toAdd,
|
List<PlexLibrary> toAdd,
|
||||||
List<PlexLibrary> toDelete);
|
List<PlexLibrary> toDelete,
|
||||||
|
List<PlexLibrary> toUpdate);
|
||||||
|
|
||||||
Task<List<int>> UpdateLibraries(
|
Task<List<int>> UpdateLibraries(
|
||||||
int jellyfinMediaSourceId,
|
int jellyfinMediaSourceId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||||
@@ -28,11 +28,14 @@ public interface IMetadataRepository
|
|||||||
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
||||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||||
Task<Unit> MarkAsUpdated(MovieMetadata 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> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated);
|
||||||
Task<Unit> MarkAsExternal(ShowMetadata metadata);
|
Task<Unit> MarkAsExternal(ShowMetadata metadata);
|
||||||
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
|
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
|
||||||
Task<Unit> MarkAsExternal(MovieMetadata metadata);
|
Task<Unit> MarkAsExternal(MovieMetadata metadata);
|
||||||
|
Task<Unit> MarkAsExternal(OtherVideoMetadata metadata);
|
||||||
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
|
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
|
||||||
|
Task<Unit> SetContentRating(OtherVideoMetadata metadata, string contentRating);
|
||||||
|
|
||||||
[SuppressMessage("Naming", "CA1720:Identifier contains type name")]
|
[SuppressMessage("Naming", "CA1720:Identifier contains type name")]
|
||||||
Task<bool> RemoveGuid(MetadataGuid guid);
|
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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "8.0.4")
|
.HasAnnotation("ProductVersion", "8.0.5")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
@@ -3484,6 +3484,19 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
|||||||
b.ToTable("PlexMovie", (string)null);
|
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 =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
|
||||||
{
|
{
|
||||||
b.HasBaseType("ErsatzTV.Core.Domain.Season");
|
b.HasBaseType("ErsatzTV.Core.Domain.Season");
|
||||||
@@ -5288,6 +5301,15 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ErsatzTV.Core.Domain.Season", null)
|
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)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.4");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||||
|
|
||||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
||||||
{
|
{
|
||||||
@@ -3323,6 +3323,19 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
b.ToTable("PlexMovie", (string)null);
|
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 =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
|
||||||
{
|
{
|
||||||
b.HasBaseType("ErsatzTV.Core.Domain.Season");
|
b.HasBaseType("ErsatzTV.Core.Domain.Season");
|
||||||
@@ -5127,6 +5140,15 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
|||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbySeason", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ErsatzTV.Core.Domain.Season", null)
|
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(
|
public async Task<List<int>> UpdateLibraries(
|
||||||
int plexMediaSourceId,
|
int plexMediaSourceId,
|
||||||
List<PlexLibrary> toAdd,
|
List<PlexLibrary> toAdd,
|
||||||
List<PlexLibrary> toDelete)
|
List<PlexLibrary> toDelete,
|
||||||
|
List<PlexLibrary> toUpdate)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
@@ -166,6 +167,23 @@ public class MediaSourceRepository : IMediaSourceRepository
|
|||||||
dbContext.PlexLibraries.Remove(delete);
|
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();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
return deletedMediaIds;
|
return deletedMediaIds;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Extensions;
|
using ErsatzTV.Core.Extensions;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
@@ -427,6 +427,30 @@ public class MetadataRepository : IMetadataRepository
|
|||||||
new { metadata.Id, ContentRating = contentRating }).ToUnit();
|
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)
|
public async Task<bool> RemoveGuid(MetadataGuid guid)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
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;
|
||||||
using ErsatzTV.Core.Domain.Filler;
|
using ErsatzTV.Core.Domain.Filler;
|
||||||
using ErsatzTV.Core.Domain.Scheduling;
|
using ErsatzTV.Core.Domain.Scheduling;
|
||||||
@@ -63,6 +63,7 @@ public class TvContext : DbContext
|
|||||||
public DbSet<Episode> Episodes { get; set; }
|
public DbSet<Episode> Episodes { get; set; }
|
||||||
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
|
||||||
public DbSet<PlexMovie> PlexMovies { get; set; }
|
public DbSet<PlexMovie> PlexMovies { get; set; }
|
||||||
|
public DbSet<PlexOtherVideo> PlexOtherVideos { get; set; }
|
||||||
public DbSet<PlexShow> PlexShows { get; set; }
|
public DbSet<PlexShow> PlexShows { get; set; }
|
||||||
public DbSet<PlexSeason> PlexSeasons { get; set; }
|
public DbSet<PlexSeason> PlexSeasons { get; set; }
|
||||||
public DbSet<PlexEpisode> PlexEpisodes { 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
|
public class PlexLibraryResponse
|
||||||
{
|
{
|
||||||
@@ -8,4 +8,6 @@ public class PlexLibraryResponse
|
|||||||
public string Agent { get; set; }
|
public string Agent { get; set; }
|
||||||
public int Hidden { get; set; }
|
public int Hidden { get; set; }
|
||||||
public string Uuid { 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 System.Xml.Serialization;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Metadata;
|
|||||||
using ErsatzTV.Core.Plex;
|
using ErsatzTV.Core.Plex;
|
||||||
using ErsatzTV.Infrastructure.Plex.Models;
|
using ErsatzTV.Infrastructure.Plex.Models;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using Refit;
|
using Refit;
|
||||||
|
|
||||||
namespace ErsatzTV.Infrastructure.Plex;
|
namespace ErsatzTV.Infrastructure.Plex;
|
||||||
@@ -50,15 +51,12 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
});
|
});
|
||||||
List<PlexLibraryResponse> directory =
|
List<PlexLibraryResponse> directory =
|
||||||
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
|
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
|
||||||
return directory
|
List<PlexLibrary> response = directory
|
||||||
// .Filter(l => l.Hidden == 0)
|
|
||||||
.Filter(l => l.Type.ToLowerInvariant() is "movie" or "show")
|
.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)
|
.Map(Project)
|
||||||
.Somes()
|
.Somes()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -110,6 +108,29 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
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(
|
public IAsyncEnumerable<Tuple<PlexSeason, int>> GetShowSeasons(
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexShow show,
|
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(
|
public async Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
|
||||||
int plexMediaSourceId,
|
int plexMediaSourceId,
|
||||||
string key,
|
string key,
|
||||||
@@ -336,8 +391,18 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
|
private static Option<PlexLibrary> Project(PlexLibraryResponse response)
|
||||||
response.Type switch
|
{
|
||||||
|
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
|
"show" => new PlexLibrary
|
||||||
{
|
{
|
||||||
@@ -345,19 +410,22 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
Name = response.Title,
|
Name = response.Title,
|
||||||
MediaKind = LibraryMediaKind.Shows,
|
MediaKind = LibraryMediaKind.Shows,
|
||||||
ShouldSyncItems = false,
|
ShouldSyncItems = false,
|
||||||
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
|
Paths = paths
|
||||||
},
|
},
|
||||||
"movie" => new PlexLibrary
|
"movie" => new PlexLibrary
|
||||||
{
|
{
|
||||||
Key = response.Key,
|
Key = response.Key,
|
||||||
Name = response.Title,
|
Name = response.Title,
|
||||||
MediaKind = LibraryMediaKind.Movies,
|
MediaKind = response.Agent == "com.plexapp.agents.none" && response.Language == "xn"
|
||||||
|
? LibraryMediaKind.OtherVideos
|
||||||
|
: LibraryMediaKind.Movies,
|
||||||
ShouldSyncItems = false,
|
ShouldSyncItems = false,
|
||||||
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
|
Paths = paths
|
||||||
},
|
},
|
||||||
// TODO: "artist" for music libraries
|
// TODO: "artist" for music libraries
|
||||||
_ => None
|
_ => None
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private Option<PlexCollection> ProjectToCollection(
|
private Option<PlexCollection> ProjectToCollection(
|
||||||
PlexMediaSource plexMediaSource,
|
PlexMediaSource plexMediaSource,
|
||||||
@@ -994,6 +1062,192 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
EndTime = TimeSpan.FromMilliseconds(chapter.EndTimeOffset)
|
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)
|
private Option<string> NormalizeGuid(string guid)
|
||||||
{
|
{
|
||||||
if (guid.StartsWith("plex://show", StringComparison.OrdinalIgnoreCase) ||
|
if (guid.StartsWith("plex://show", StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -1037,4 +1291,10 @@ public class PlexServerApiClient : IPlexServerApiClient
|
|||||||
|
|
||||||
return None;
|
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.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
@@ -16,6 +16,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
|||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
||||||
|
private readonly IPlexOtherVideoLibraryScanner _plexOtherVideoLibraryScanner;
|
||||||
private readonly IPlexSecretStore _plexSecretStore;
|
private readonly IPlexSecretStore _plexSecretStore;
|
||||||
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
|
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
|||||||
IConfigElementRepository configElementRepository,
|
IConfigElementRepository configElementRepository,
|
||||||
IPlexSecretStore plexSecretStore,
|
IPlexSecretStore plexSecretStore,
|
||||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||||
|
IPlexOtherVideoLibraryScanner plexOtherVideoLibraryScanner,
|
||||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||||
ILibraryRepository libraryRepository,
|
ILibraryRepository libraryRepository,
|
||||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||||
@@ -34,6 +36,7 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
|||||||
_configElementRepository = configElementRepository;
|
_configElementRepository = configElementRepository;
|
||||||
_plexSecretStore = plexSecretStore;
|
_plexSecretStore = plexSecretStore;
|
||||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||||
|
_plexOtherVideoLibraryScanner = plexOtherVideoLibraryScanner;
|
||||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||||
_libraryRepository = libraryRepository;
|
_libraryRepository = libraryRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -66,6 +69,13 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
|||||||
parameters.Library,
|
parameters.Library,
|
||||||
parameters.DeepScan,
|
parameters.DeepScan,
|
||||||
cancellationToken),
|
cancellationToken),
|
||||||
|
LibraryMediaKind.OtherVideos =>
|
||||||
|
await _plexOtherVideoLibraryScanner.ScanLibrary(
|
||||||
|
parameters.ConnectionParameters.ActiveConnection,
|
||||||
|
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||||
|
parameters.Library,
|
||||||
|
parameters.DeepScan,
|
||||||
|
cancellationToken),
|
||||||
LibraryMediaKind.Shows =>
|
LibraryMediaKind.Shows =>
|
||||||
await _plexTelevisionLibraryScanner.ScanLibrary(
|
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||||
parameters.ConnectionParameters.ActiveConnection,
|
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 Bugsnag.Payload;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
@@ -198,11 +198,13 @@ public class Program
|
|||||||
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
|
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
|
||||||
|
|
||||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||||
|
services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>();
|
||||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||||
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
|
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
|
||||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||||
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
|
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
|
||||||
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
|
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
|
||||||
|
services.AddScoped<IPlexOtherVideoRepository, PlexOtherVideoRepository>();
|
||||||
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
|
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
|
||||||
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
|
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
|
||||||
services.AddScoped<PlexEtag>();
|
services.AddScoped<PlexEtag>();
|
||||||
|
|||||||
Reference in New Issue
Block a user