add plex network metadata (#2085)
* initial plumbing * scan for plex networks * save network contents to db as tags * eliminate network tag id churn * add network and show_network to search index * update last networks scan * show networks on tv show page * update changelog
This commit is contained in:
@@ -33,6 +33,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `Active` - default value, channel streams as normal and has normal visibility
|
||||
- `Hidden` - channel streams as normal and is hidden from M3U/XMLTV/HDHR
|
||||
- `Inactive` - channel cannot stream (will 404) and is hidden from M3U/XMLTV/HDHR
|
||||
- Synchronize Plex "network" metadata for Plex show libraries
|
||||
- Shows will have new `network` search field
|
||||
- Episodes will have new `show_network` search field
|
||||
|
||||
### Fixed
|
||||
- Fix QSV acceleration in docker with older Intel devices
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexNetworkScannerHandler : CallLibraryScannerHandler<SynchronizePlexNetworks>,
|
||||
IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
|
||||
{
|
||||
public CallPlexNetworkScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(SynchronizePlexNetworks request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizePlexNetworks request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
|
||||
.Match(l => l.LastNetworksScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
|
||||
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
SynchronizePlexNetworks request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformScan(
|
||||
string scanner,
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex-networks", request.PlexLibraryId.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -22,21 +22,21 @@ internal static class Mapper
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
|
||||
.IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone([]),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(g => g.Name).ToList()).IfNone([]),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone([]),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId).Map(g => g.Name).ToList()).IfNone([]),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => (m.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone(new List<string>()),
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone([]),
|
||||
LanguagesForShow(languages),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
|
||||
.ToList())
|
||||
.IfNone(new List<ActorCardViewModel>()));
|
||||
.IfNone([]));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(
|
||||
Season season,
|
||||
|
||||
@@ -13,6 +13,7 @@ public record TelevisionShowViewModel(
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<string> Networks,
|
||||
List<string> ContentRatings,
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors);
|
||||
|
||||
@@ -21,6 +21,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class PlexTag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Filter { get; set; }
|
||||
public string Tag { get; set; }
|
||||
public int TagType { get; set; }
|
||||
}
|
||||
@@ -4,4 +4,5 @@ public class PlexLibrary : Library
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public bool ShouldSyncItems { get; set; }
|
||||
public DateTime? LastNetworksScan { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
public class Tag
|
||||
{
|
||||
public static readonly string PlexNetworkTypeId = "319";
|
||||
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string ExternalCollectionId { get; set; }
|
||||
public string ExternalTypeId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
public interface IPlexNetworkScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanNetworks(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -76,4 +76,16 @@ public interface IPlexServerApiClient
|
||||
PlexServerAuthToken token,
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<Tuple<PlexTag, int>> GetAllTags(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
int tagType,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
IAsyncEnumerable<Tuple<PlexShow, int>> GetTagShowContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexTag tag);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason,
|
||||
PlexEpisode, PlexItemEtag>
|
||||
{
|
||||
Task<List<int>> RemoveAllTags(PlexLibrary library, PlexTag tag, System.Collections.Generic.HashSet<int> keep);
|
||||
Task<PlexShowAddTagResult> AddTag(PlexShow show, PlexTag tag);
|
||||
Task UpdateLastNetworksScan(PlexLibrary library);
|
||||
}
|
||||
|
||||
public record PlexShowAddTagResult(Option<int> Existing, Option<int> Added);
|
||||
|
||||
@@ -14,6 +14,7 @@ public interface ITelevisionRepository
|
||||
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
|
||||
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<int>> GetEpisodeIdsForShow(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
Task<Option<Season>> GetSeason(int seasonId);
|
||||
Task<int> GetSeasonCount(int showId);
|
||||
|
||||
+5896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_TagExternalTypeId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalTypeId",
|
||||
table: "Tag",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalTypeId",
|
||||
table: "Tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+5899
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_PlexLibraryLastNetworksScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastNetworksScan",
|
||||
table: "PlexLibrary",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastNetworksScan",
|
||||
table: "PlexLibrary");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3008,6 +3008,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<string>("ExternalCollectionId")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("ExternalTypeId")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int?>("ImageMetadataId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3270,6 +3273,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime?>("LastNetworksScan")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<bool>("ShouldSyncItems")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
||||
Generated
+5735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_TagExternalTypeId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalTypeId",
|
||||
table: "Tag",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalTypeId",
|
||||
table: "Tag");
|
||||
}
|
||||
}
|
||||
}
|
||||
ErsatzTV.Infrastructure.Sqlite/Migrations/20250628002724_Add_PlexLibraryLastNetworksScan.Designer.cs
Generated
+5738
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_PlexLibraryLastNetworksScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastNetworksScan",
|
||||
table: "PlexLibrary",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastNetworksScan",
|
||||
table: "PlexLibrary");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2859,6 +2859,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<string>("ExternalCollectionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExternalTypeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ImageMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -3109,6 +3112,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("LastNetworksScan")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ShouldSyncItems")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dapper;
|
||||
using System.Globalization;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -570,4 +571,76 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
return BaseError.New("Failed to update episode path");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveAllTags(
|
||||
PlexLibrary library,
|
||||
PlexTag tag,
|
||||
System.Collections.Generic.HashSet<int> keep)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var tagType = tag.TagType.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
List<int> result = await dbContext.ShowMetadata
|
||||
.Where(sm => !keep.Contains(sm.ShowId))
|
||||
.Where(sm => sm.Show.LibraryPath.LibraryId == library.Id)
|
||||
.Where(sm => sm.Tags.Any(t => t.Name == tag.Tag && t.ExternalTypeId == tagType))
|
||||
.Select(sm => sm.ShowId)
|
||||
.ToListAsync();
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
List<int> tagIds = await dbContext.ShowMetadata
|
||||
.Where(sm => result.Contains(sm.ShowId))
|
||||
.Where(sm => sm.Tags.Any(t => t.Name == tag.Tag && t.ExternalTypeId == tagType))
|
||||
.SelectMany(sm => sm.Tags.Select(t => t.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// delete all tags
|
||||
await dbContext.Connection.ExecuteAsync("DELETE FROM Tag WHERE Id IN @TagIds", new { TagIds = tagIds });
|
||||
}
|
||||
|
||||
// show ids to refresh
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<PlexShowAddTagResult> AddTag(PlexShow show, PlexTag tag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
int existingShowId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT PS.Id FROM Tag
|
||||
INNER JOIN ShowMetadata SM on SM.Id = Tag.ShowMetadataId
|
||||
INNER JOIN PlexShow PS on PS.Id = SM.ShowId
|
||||
WHERE PS.Key = @Key AND Tag.Name = @Tag AND Tag.ExternalTypeId = @TagType",
|
||||
new { show.Key, tag.Tag, tag.TagType });
|
||||
|
||||
// already exists
|
||||
if (existingShowId > 0)
|
||||
{
|
||||
return new PlexShowAddTagResult(existingShowId, Option<int>.None);
|
||||
}
|
||||
|
||||
int showId = await dbContext.PlexShows
|
||||
.Where(s => s.Key == show.Key)
|
||||
.Select(s => s.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"INSERT INTO Tag (Name, ExternalTypeId, ShowMetadataId)
|
||||
SELECT @Tag, @TagType, Id FROM
|
||||
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId) AS A",
|
||||
new { tag.Tag, tag.TagType, ShowId = showId });
|
||||
|
||||
// show id to refresh
|
||||
return new PlexShowAddTagResult(Option<int>.None, showId);
|
||||
}
|
||||
|
||||
public async Task UpdateLastNetworksScan(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexLibrary SET LastNetworksScan = @LastNetworksScan WHERE Id = @Id",
|
||||
new { library.LastNetworksScan, library.Id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,17 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetEpisodeIdsForShow(int showId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return (await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT Episode.Id FROM `Show`
|
||||
INNER JOIN Season ON Season.ShowId = `Show`.Id
|
||||
INNER JOIN Episode ON Episode.SeasonId = Season.Id
|
||||
WHERE `Show`.Id = @ShowId",
|
||||
new { ShowId = showId })).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<Season>> GetAllSeasons()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using System.Collections.Specialized;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using Refit;
|
||||
using CollectionFormat = Refit.CollectionFormat;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex;
|
||||
|
||||
@@ -73,6 +75,48 @@ public interface IPlexServerApi
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/tags?type={type}&X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> GetTagsCount(
|
||||
int type,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/tags?type={type}")]
|
||||
[Headers("Accept: application/json")]
|
||||
public Task<PlexMediaContainerResponse<PlexMediaContainerDirectoryContent<PlexTagMetadataResponse>>>
|
||||
GetTags(
|
||||
int type,
|
||||
[Query] [AliasAs("X-Plex-Container-Start")]
|
||||
int skip,
|
||||
[Query] [AliasAs("X-Plex-Container-Size")]
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/sections/{key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> CountTagContents(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token,
|
||||
[Query]
|
||||
NetworkFilter filter);
|
||||
|
||||
[Get("/library/sections/{key}/all")]
|
||||
[Headers("Accept: application/json")]
|
||||
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
|
||||
GetTagContents(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Container-Start")]
|
||||
int skip,
|
||||
[Query] [AliasAs("X-Plex-Container-Size")]
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token,
|
||||
[Query]
|
||||
NetworkFilter filter);
|
||||
|
||||
[Get("/library/metadata/{key}?includeChapters=1")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlVideoMetadataResponseContainer>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models;
|
||||
|
||||
public class PlexTagMetadataResponse
|
||||
{
|
||||
[XmlAttribute("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[XmlAttribute("filter")]
|
||||
public string Filter { get; set; }
|
||||
|
||||
[XmlAttribute("tag")]
|
||||
public string Tag { get; set; }
|
||||
|
||||
[XmlAttribute("tagType")]
|
||||
public int TagType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex;
|
||||
|
||||
public class NetworkFilter
|
||||
{
|
||||
[AliasAs("network")]
|
||||
public string Network { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Web;
|
||||
using System.Xml.Serialization;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -340,6 +342,60 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Tuple<PlexTag, int>> GetAllTags(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
int tagType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
return service.GetTagsCount(tagType, token.AuthToken);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexTag>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
|
||||
{
|
||||
return jsonService
|
||||
.GetTags(tagType, skip, pageSize, token.AuthToken)
|
||||
.Map(r => r.MediaContainer.Directory)
|
||||
.Map(list => list.Map(ProjectToTag).Somes());
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<Tuple<PlexShow, int>> GetTagShowContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexTag tag)
|
||||
{
|
||||
string network = string.Empty;
|
||||
|
||||
NameValueCollection parsedFilter = HttpUtility.ParseQueryString(tag.Filter);
|
||||
foreach (string key in parsedFilter.AllKeys.Filter(k => k is not null))
|
||||
{
|
||||
network = parsedFilter[key];
|
||||
}
|
||||
|
||||
var filter = new NetworkFilter { Network = network };
|
||||
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
return service.CountTagContents(library.Key, token.AuthToken, filter);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexShow>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
|
||||
{
|
||||
return jsonService
|
||||
.GetTagContents(library.Key, skip, pageSize, token.AuthToken, filter)
|
||||
.Map(r => r.MediaContainer.Metadata ?? [])
|
||||
.Map(list => list.Map(metadata => ProjectToShow(metadata, library.MediaSourceId)));
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryContents<TItem>(
|
||||
PlexConnection connection,
|
||||
Func<IPlexServerApi, Task<PlexXmlMediaContainerStatsResponse>> countItems,
|
||||
@@ -479,6 +535,25 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
private Option<PlexTag> ProjectToTag(PlexTagMetadataResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new PlexTag
|
||||
{
|
||||
Id = item.Id,
|
||||
Filter = item.Filter,
|
||||
Tag = item.Tag,
|
||||
TagType = item.TagType
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Plex tag");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
|
||||
{
|
||||
PlexMediaResponse<PlexPartResponse> media = response.Media
|
||||
|
||||
@@ -50,7 +50,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
return exists.IsValidResponse;
|
||||
}
|
||||
|
||||
public int Version => 45;
|
||||
public int Version => 47;
|
||||
|
||||
public async Task<bool> Initialize(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -236,6 +236,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
.Text(t => t.Tag, t => t.Store(false))
|
||||
.Keyword(t => t.TagFull, t => t.Store(false))
|
||||
.Text(t => t.Studio, t => t.Store(false))
|
||||
.Text(t => t.Network, t => t.Store(false))
|
||||
.Text(t => t.Actor, t => t.Store(false))
|
||||
.Text(t => t.Director, t => t.Store(false))
|
||||
.Text(t => t.Writer, t => t.Store(false))
|
||||
@@ -245,6 +246,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
.Text(t => t.ShowGenre, t => t.Store(false))
|
||||
.Text(t => t.ShowTag, t => t.Store(false))
|
||||
.Text(t => t.ShowStudio, t => t.Store(false))
|
||||
.Text(t => t.ShowNetwork, t => t.Store(false))
|
||||
.Keyword(t => t.ShowContentRating, t => t.Store(false))
|
||||
.Text(t => t.Style, t => t.Store(false))
|
||||
.Text(t => t.Mood, t => t.Store(false))
|
||||
@@ -372,9 +374,10 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
AddedDate = GetAddedDate(metadata.DateAdded),
|
||||
Plot = metadata.Plot ?? string.Empty,
|
||||
Genre = metadata.Genres.Map(g => g.Name).ToList(),
|
||||
Tag = metadata.Tags.Map(t => t.Name).ToList(),
|
||||
TagFull = metadata.Tags.Map(t => t.Name).ToList(),
|
||||
Tag = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name).ToList(),
|
||||
TagFull = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name).ToList(),
|
||||
Studio = metadata.Studios.Map(s => s.Name).ToList(),
|
||||
Network = metadata.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId).Map(t => t.Name).ToList(),
|
||||
Actor = metadata.Actors.Map(a => a.Name).ToList(),
|
||||
TraktList = show.TraktListItems.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture))
|
||||
.ToList()
|
||||
@@ -628,8 +631,9 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
{
|
||||
doc.ShowTitle = showMetadata.Title;
|
||||
doc.ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList();
|
||||
doc.ShowTag = showMetadata.Tags.Map(t => t.Name).ToList();
|
||||
doc.ShowTag = showMetadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name).ToList();
|
||||
doc.ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList();
|
||||
doc.ShowNetwork = showMetadata.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId).Map(t => t.Name).ToList();
|
||||
doc.ShowContentRating = GetContentRatings(showMetadata.ContentRating);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
internal const string TitleAndYearField = "title_and_year";
|
||||
internal const string JumpLetterField = "jump_letter";
|
||||
internal const string StudioField = "studio";
|
||||
internal const string NetworkField = "network";
|
||||
internal const string LanguageField = "language";
|
||||
internal const string LanguageTagField = "language_tag";
|
||||
internal const string SubLanguageField = "sub_language";
|
||||
@@ -61,6 +62,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
internal const string ShowGenreField = "show_genre";
|
||||
internal const string ShowTagField = "show_tag";
|
||||
internal const string ShowStudioField = "show_studio";
|
||||
internal const string ShowNetworkField = "show_network";
|
||||
internal const string ShowContentRatingField = "show_content_rating";
|
||||
internal const string MetadataKindField = "metadata_kind";
|
||||
internal const string VideoCodecField = "video_codec";
|
||||
@@ -114,7 +116,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
return Task.FromResult(directoryExists && fileExists);
|
||||
}
|
||||
|
||||
public int Version => 46;
|
||||
public int Version => 47;
|
||||
|
||||
public async Task<bool> Initialize(
|
||||
ILocalFileSystem localFileSystem,
|
||||
@@ -654,8 +656,15 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
|
||||
foreach (Tag tag in metadata.Tags)
|
||||
{
|
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
|
||||
doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO));
|
||||
if (tag.ExternalTypeId == Tag.PlexNetworkTypeId)
|
||||
{
|
||||
doc.Add(new TextField(NetworkField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
else
|
||||
{
|
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
|
||||
doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios)
|
||||
@@ -1026,7 +1035,14 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
|
||||
foreach (Tag tag in showMetadata.Tags)
|
||||
{
|
||||
doc.Add(new TextField(ShowTagField, tag.Name, Field.Store.NO));
|
||||
if (tag.ExternalTypeId == Tag.PlexNetworkTypeId)
|
||||
{
|
||||
doc.Add(new TextField(ShowNetworkField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
else
|
||||
{
|
||||
doc.Add(new TextField(ShowTagField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in showMetadata.Studios)
|
||||
|
||||
@@ -91,6 +91,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
||||
[JsonPropertyName(LuceneSearchIndex.StudioField)]
|
||||
public List<string> Studio { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.NetworkField)]
|
||||
public List<string> Network { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.ArtistField)]
|
||||
public List<string> Artist { get; set; }
|
||||
|
||||
@@ -124,6 +127,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
||||
[JsonPropertyName(LuceneSearchIndex.ShowStudioField)]
|
||||
public List<string> ShowStudio { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.ShowNetworkField)]
|
||||
public List<string> ShowNetwork { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.ShowContentRatingField)]
|
||||
public List<string> ShowContentRating { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Scanner.Application.Plex;
|
||||
|
||||
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,126 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Scanner.Application.Plex;
|
||||
|
||||
public class SynchronizePlexNetworksHandler : IRequestHandler<SynchronizePlexNetworks, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IPlexTelevisionRepository _plexTelevisionRepository;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly IPlexNetworkScanner _scanner;
|
||||
|
||||
public SynchronizePlexNetworksHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexNetworkScanner scanner,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IPlexTelevisionRepository plexTelevisionRepository)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_scanner = scanner;
|
||||
_configElementRepository = configElementRepository;
|
||||
_plexTelevisionRepository = plexTelevisionRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
p => SynchronizeNetworks(p, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexNetworks request)
|
||||
{
|
||||
Task<Validation<BaseError, ConnectionParameters>> mediaSource = MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveToken);
|
||||
|
||||
return (await mediaSource, await PlexLibraryMustExist(request), await ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, PlexLibrary>> PlexLibraryMustExist(
|
||||
SynchronizePlexNetworks request) =>
|
||||
_mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Plex library {request.PlexLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri is >= 0 and < 1_000_000)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> MediaSourceMustExist(
|
||||
SynchronizePlexNetworks request) =>
|
||||
_mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId)
|
||||
.Map(o => o.ToValidation<BaseError>($"Plex media source for library {request.PlexLibraryId} does not exist."));
|
||||
|
||||
private static Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
PlexMediaSource plexMediaSource)
|
||||
{
|
||||
Option<PlexConnection> maybeConnection = plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
|
||||
.ToValidation<BaseError>("Plex media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
Option<PlexServerAuthToken> maybeToken = await
|
||||
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
|
||||
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
|
||||
.ToValidation<BaseError>("Plex media source requires a token");
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> SynchronizeNetworks(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(
|
||||
parameters.Library.LastNetworksScan ?? SystemTime.MinValueUtc,
|
||||
TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || parameters.LibraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
Either<BaseError, Unit> result = await _scanner.ScanNetworks(
|
||||
parameters.Library,
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
parameters.Library.LastNetworksScan = DateTime.UtcNow;
|
||||
await _plexTelevisionRepository.UpdateLastNetworksScan(parameters.Library);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record RequestParameters(
|
||||
ConnectionParameters ConnectionParameters,
|
||||
PlexLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
{
|
||||
public PlexServerAuthToken? PlexServerAuthToken { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Core.Plex;
|
||||
|
||||
public class PlexNetworkScanner(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IPlexTelevisionRepository plexTelevisionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediator mediator,
|
||||
ILogger<PlexNetworkScanner> logger) : IPlexNetworkScanner
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> ScanNetworks(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// logger.LogDebug("Scanning Plex networks...");
|
||||
|
||||
await foreach ((PlexTag tag, int _) in plexServerApiClient.GetAllTags(
|
||||
connection,
|
||||
token,
|
||||
319,
|
||||
cancellationToken))
|
||||
{
|
||||
// logger.LogDebug("Found Plex network {Tag}", tag.Tag);
|
||||
|
||||
await SyncNetworkItems(library, connection, token, tag, cancellationToken);
|
||||
}
|
||||
|
||||
return Either<BaseError, Unit>.Right(Unit.Default);
|
||||
}
|
||||
|
||||
private async Task SyncNetworkItems(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexTag tag,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// get network items from Plex
|
||||
IAsyncEnumerable<Tuple<PlexShow, int>> items = plexServerApiClient.GetTagShowContents(
|
||||
library,
|
||||
connection,
|
||||
token,
|
||||
tag);
|
||||
|
||||
// sync tags (networks) on items
|
||||
var addedIds = new System.Collections.Generic.HashSet<int>();
|
||||
var keepIds = new System.Collections.Generic.HashSet<int>();
|
||||
await foreach ((PlexShow item, int _) in items)
|
||||
{
|
||||
PlexShowAddTagResult result = await plexTelevisionRepository.AddTag(item, tag);
|
||||
|
||||
foreach (int existing in result.Existing)
|
||||
{
|
||||
keepIds.Add(existing);
|
||||
}
|
||||
|
||||
foreach (int added in result.Added)
|
||||
{
|
||||
addedIds.Add(added);
|
||||
keepIds.Add(added);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
List<int> removedIds = await plexTelevisionRepository.RemoveAllTags(library, tag, keepIds);
|
||||
var changedIds = removedIds.Concat(addedIds).Distinct().ToList();
|
||||
|
||||
if (changedIds.Count > 0)
|
||||
{
|
||||
logger.LogDebug("Plex network {Name} contains {Count} changed items", tag.Tag, changedIds.Count);
|
||||
}
|
||||
|
||||
foreach (int showId in changedIds.ToArray())
|
||||
{
|
||||
changedIds.AddRange(await televisionRepository.GetEpisodeIdsForShow(showId));
|
||||
}
|
||||
|
||||
await mediator.Publish(
|
||||
new ScannerProgressUpdate(0, null, null, changedIds.ToArray(), []),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to synchronize Plex network {Name}", tag.Tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ public class Program
|
||||
services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>();
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
|
||||
services.AddScoped<IPlexNetworkScanner, PlexNetworkScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
|
||||
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
|
||||
|
||||
@@ -79,6 +79,10 @@ public class Worker : BackgroundService
|
||||
scanPlexCollectionsCommand.Arguments.Add(mediaSourceIdArgument);
|
||||
scanPlexCollectionsCommand.Options.Add(forceOption);
|
||||
|
||||
var scanPlexNetworksCommand = new Command("scan-plex-networks", "Scan Plex networks");
|
||||
scanPlexNetworksCommand.Arguments.Add(libraryIdArgument);
|
||||
scanPlexNetworksCommand.Options.Add(forceOption);
|
||||
|
||||
var scanEmbyCommand = new Command("scan-emby", "Scan an Emby library");
|
||||
scanEmbyCommand.Arguments.Add(libraryIdArgument);
|
||||
scanEmbyCommand.Options.Add(forceOption);
|
||||
@@ -152,6 +156,24 @@ public class Worker : BackgroundService
|
||||
}
|
||||
});
|
||||
|
||||
scanPlexNetworksCommand.SetAction(
|
||||
async (parseResult, token) =>
|
||||
{
|
||||
if (IsScanningEnabled())
|
||||
{
|
||||
bool force = parseResult.GetValue(forceOption);
|
||||
SetProcessPriority(force);
|
||||
|
||||
int libraryId = parseResult.GetValue(libraryIdArgument);
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
var scan = new SynchronizePlexNetworks(libraryId, force);
|
||||
await mediator.Send(scan, token);
|
||||
}
|
||||
});
|
||||
|
||||
scanEmbyCommand.SetAction(
|
||||
async (parseResult, token) =>
|
||||
{
|
||||
@@ -230,6 +252,7 @@ public class Worker : BackgroundService
|
||||
rootCommand.Subcommands.Add(scanLocalCommand);
|
||||
rootCommand.Subcommands.Add(scanPlexCommand);
|
||||
rootCommand.Subcommands.Add(scanPlexCollectionsCommand);
|
||||
rootCommand.Subcommands.Add(scanPlexNetworksCommand);
|
||||
rootCommand.Subcommands.Add(scanEmbyCommand);
|
||||
rootCommand.Subcommands.Add(scanEmbyCollectionsCommand);
|
||||
rootCommand.Subcommands.Add(scanJellyfinCommand);
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
case PlexLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), _cts.Token);
|
||||
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token);
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(library.Id, true), _cts.Token);
|
||||
break;
|
||||
case JellyfinLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token);
|
||||
|
||||
@@ -116,6 +116,18 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedNetworks.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Networks: </MudText>
|
||||
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
|
||||
@foreach (string network in _sortedNetworks.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"network:""{network.ToLowerInvariant()}""".GetRelativeSearchQuery())">@network</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedGenres.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
@@ -176,11 +188,12 @@
|
||||
public int ShowId { get; set; }
|
||||
|
||||
private TelevisionShowViewModel _show;
|
||||
private List<string> _sortedContentRatings = new();
|
||||
private List<CultureInfo> _sortedLanguages = new();
|
||||
private List<string> _sortedStudios = new();
|
||||
private List<string> _sortedGenres = new();
|
||||
private List<string> _sortedTags = new();
|
||||
private List<string> _sortedContentRatings = [];
|
||||
private List<CultureInfo> _sortedLanguages = [];
|
||||
private List<string> _sortedStudios = [];
|
||||
private List<string> _sortedNetworks = [];
|
||||
private List<string> _sortedGenres = [];
|
||||
private List<string> _sortedTags = [];
|
||||
|
||||
private int _pageSize => 100;
|
||||
private readonly int _pageNumber = 1;
|
||||
@@ -197,17 +210,17 @@
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
await Mediator.Send(new GetTelevisionShowById(ShowId), _cts.Token)
|
||||
.IfSomeAsync(
|
||||
vm =>
|
||||
{
|
||||
_show = vm;
|
||||
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
|
||||
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
|
||||
});
|
||||
Option<TelevisionShowViewModel> maybeShow = await Mediator.Send(new GetTelevisionShowById(ShowId), _cts.Token);
|
||||
foreach (TelevisionShowViewModel show in maybeShow)
|
||||
{
|
||||
_show = show;
|
||||
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
|
||||
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
|
||||
_sortedNetworks = _show.Networks.OrderBy(n => n).ToList();
|
||||
}
|
||||
|
||||
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize), _cts.Token);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ public class ScannerService : BackgroundService
|
||||
case SynchronizePlexCollections synchronizePlexCollections:
|
||||
requestTask = SynchronizePlexCollections(synchronizePlexCollections, stoppingToken);
|
||||
break;
|
||||
case SynchronizePlexNetworks synchronizePlexNetworks:
|
||||
requestTask = SynchronizePlexNetworks(synchronizePlexNetworks, stoppingToken);
|
||||
break;
|
||||
case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries:
|
||||
requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, stoppingToken);
|
||||
break;
|
||||
@@ -220,6 +223,40 @@ public class ScannerService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SynchronizePlexNetworks(
|
||||
SynchronizePlexNetworks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
_ => _logger.LogDebug("Done synchronizing plex networks for library {LibraryId}", request.PlexLibraryId),
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex networks in library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex networks for library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SynchronizeLibraries(SynchronizeJellyfinLibraries request, CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
@@ -247,6 +247,13 @@ public class SchedulerService : BackgroundService
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexLibraryByIdIfNeeded(library.Id),
|
||||
cancellationToken);
|
||||
|
||||
if (library.MediaKind is LibraryMediaKind.Shows)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexNetworks(library.Id, false),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -480,7 +480,7 @@ public class Startup
|
||||
app.UseCors("AllowAll");
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// app.UseHttpLogging();
|
||||
//app.UseHttpLogging();
|
||||
app.UseSerilogRequestLogging(
|
||||
options =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user