Browse Source

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
pull/2086/head
Jason Dove 2 months ago committed by GitHub
parent
commit
0f795e4e2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 83
      ErsatzTV.Application/Plex/Commands/CallPlexNetworkScannerHandler.cs
  3. 6
      ErsatzTV.Application/Plex/Commands/SynchronizePlexNetworks.cs
  4. 12
      ErsatzTV.Application/Television/Mapper.cs
  5. 1
      ErsatzTV.Application/Television/TelevisionShowViewModel.cs
  6. 1
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  7. 9
      ErsatzTV.Core/Domain/Collection/PlexTag.cs
  8. 1
      ErsatzTV.Core/Domain/Library/PlexLibrary.cs
  9. 3
      ErsatzTV.Core/Domain/Metadata/Tag.cs
  10. 13
      ErsatzTV.Core/Interfaces/Plex/IPlexNetworkScanner.cs
  11. 12
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  12. 5
      ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs
  13. 1
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  14. 5896
      ErsatzTV.Infrastructure.MySql/Migrations/20250627233410_Add_TagExternalTypeId.Designer.cs
  15. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250627233410_Add_TagExternalTypeId.cs
  16. 5899
      ErsatzTV.Infrastructure.MySql/Migrations/20250628002704_Add_PlexLibraryLastNetworksScan.Designer.cs
  17. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250628002704_Add_PlexLibraryLastNetworksScan.cs
  18. 6
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  19. 5735
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250627233314_Add_TagExternalTypeId.Designer.cs
  20. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250627233314_Add_TagExternalTypeId.cs
  21. 5738
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250628002724_Add_PlexLibraryLastNetworksScan.Designer.cs
  22. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250628002724_Add_PlexLibraryLastNetworksScan.cs
  23. 6
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  24. 75
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  25. 11
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  26. 46
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  27. 18
      ErsatzTV.Infrastructure/Plex/Models/PlexTagMetadataResponse.cs
  28. 9
      ErsatzTV.Infrastructure/Plex/NetworkFilter.cs
  29. 75
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  30. 12
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  31. 24
      ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs
  32. 6
      ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs
  33. 5
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexNetworks.cs
  34. 126
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexNetworksHandler.cs
  35. 99
      ErsatzTV.Scanner/Core/Plex/PlexNetworkScanner.cs
  36. 1
      ErsatzTV.Scanner/Program.cs
  37. 23
      ErsatzTV.Scanner/Worker.cs
  38. 1
      ErsatzTV/Pages/Libraries.razor
  39. 45
      ErsatzTV/Pages/TelevisionSeasonList.razor
  40. 37
      ErsatzTV/Services/ScannerService.cs
  41. 7
      ErsatzTV/Services/SchedulerService.cs
  42. 2
      ErsatzTV/Startup.cs

3
CHANGELOG.md

@ -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 - `Active` - default value, channel streams as normal and has normal visibility
- `Hidden` - channel streams as normal and is hidden from M3U/XMLTV/HDHR - `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 - `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 ### Fixed
- Fix QSV acceleration in docker with older Intel devices - Fix QSV acceleration in docker with older Intel devices

83
ErsatzTV.Application/Plex/Commands/CallPlexNetworkScannerHandler.cs

@ -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);
}
}

6
ErsatzTV.Application/Plex/Commands/SynchronizePlexNetworks.cs

@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

12
ErsatzTV.Application/Television/Mapper.cs

@ -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 => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)).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 => 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.Genres.Map(g => g.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()), 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()) show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone([]),
.IfNone(new List<string>()), show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId).Map(g => g.Name).ToList()).IfNone([]),
show.ShowMetadata.HeadOrNone() show.ShowMetadata.HeadOrNone()
.Map( .Map(
m => (m.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim()) 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), LanguagesForShow(languages),
show.ShowMetadata.HeadOrNone() show.ShowMetadata.HeadOrNone()
.Map( .Map(
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id) m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby)) .Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList()) .ToList())
.IfNone(new List<ActorCardViewModel>())); .IfNone([]));
internal static TelevisionSeasonViewModel ProjectToViewModel( internal static TelevisionSeasonViewModel ProjectToViewModel(
Season season, Season season,

1
ErsatzTV.Application/Television/TelevisionShowViewModel.cs

@ -13,6 +13,7 @@ public record TelevisionShowViewModel(
List<string> Genres, List<string> Genres,
List<string> Tags, List<string> Tags,
List<string> Studios, List<string> Studios,
List<string> Networks,
List<string> ContentRatings, List<string> ContentRatings,
List<CultureInfo> Languages, List<CultureInfo> Languages,
List<ActorCardViewModel> Actors); List<ActorCardViewModel> Actors);

1
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -21,6 +21,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException(); public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => 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(); public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();

9
ErsatzTV.Core/Domain/Collection/PlexTag.cs

@ -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; }
}

1
ErsatzTV.Core/Domain/Library/PlexLibrary.cs

@ -4,4 +4,5 @@ public class PlexLibrary : Library
{ {
public string Key { get; set; } public string Key { get; set; }
public bool ShouldSyncItems { get; set; } public bool ShouldSyncItems { get; set; }
public DateTime? LastNetworksScan { get; set; }
} }

3
ErsatzTV.Core/Domain/Metadata/Tag.cs

@ -2,7 +2,10 @@
public class Tag public class Tag
{ {
public static readonly string PlexNetworkTypeId = "319";
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string ExternalCollectionId { get; set; } public string ExternalCollectionId { get; set; }
public string ExternalTypeId { get; set; }
} }

13
ErsatzTV.Core/Interfaces/Plex/IPlexNetworkScanner.cs

@ -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);
}

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

@ -76,4 +76,16 @@ public interface IPlexServerApiClient
PlexServerAuthToken token, PlexServerAuthToken token,
string key, string key,
CancellationToken cancellationToken); 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);
} }

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

@ -6,4 +6,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason, public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason,
PlexEpisode, PlexItemEtag> 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);

1
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -14,6 +14,7 @@ public interface ITelevisionRepository
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids); Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids); Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId); Task<List<Episode>> GetShowItems(int showId);
Task<List<int>> GetEpisodeIdsForShow(int showId);
Task<List<Season>> GetAllSeasons(); Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId); Task<Option<Season>> GetSeason(int seasonId);
Task<int> GetSeasonCount(int showId); Task<int> GetSeasonCount(int showId);

5896
ErsatzTV.Infrastructure.MySql/Migrations/20250627233410_Add_TagExternalTypeId.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250627233410_Add_TagExternalTypeId.cs

@ -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");
}
}
}

5899
ErsatzTV.Infrastructure.MySql/Migrations/20250628002704_Add_PlexLibraryLastNetworksScan.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250628002704_Add_PlexLibraryLastNetworksScan.cs

@ -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");
}
}
}

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

@ -3008,6 +3008,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("ExternalCollectionId") b.Property<string>("ExternalCollectionId")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<string>("ExternalTypeId")
.HasColumnType("longtext");
b.Property<int?>("ImageMetadataId") b.Property<int?>("ImageMetadataId")
.HasColumnType("int"); .HasColumnType("int");
@ -3270,6 +3273,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Key") b.Property<string>("Key")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<DateTime?>("LastNetworksScan")
.HasColumnType("datetime(6)");
b.Property<bool>("ShouldSyncItems") b.Property<bool>("ShouldSyncItems")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");

5735
ErsatzTV.Infrastructure.Sqlite/Migrations/20250627233314_Add_TagExternalTypeId.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20250627233314_Add_TagExternalTypeId.cs

@ -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");
}
}
}

5738
ErsatzTV.Infrastructure.Sqlite/Migrations/20250628002724_Add_PlexLibraryLastNetworksScan.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20250628002724_Add_PlexLibraryLastNetworksScan.cs

@ -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");
}
}
}

6
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -2859,6 +2859,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("ExternalCollectionId") b.Property<string>("ExternalCollectionId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ExternalTypeId")
.HasColumnType("TEXT");
b.Property<int?>("ImageMetadataId") b.Property<int?>("ImageMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -3109,6 +3112,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Key") b.Property<string>("Key")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<DateTime?>("LastNetworksScan")
.HasColumnType("TEXT");
b.Property<bool>("ShouldSyncItems") b.Property<bool>("ShouldSyncItems")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

75
ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs

@ -1,4 +1,5 @@
using Dapper; using System.Globalization;
using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
@ -570,4 +571,76 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
return BaseError.New("Failed to update episode path"); 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 });
}
} }

11
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -136,6 +136,17 @@ public class TelevisionRepository : ITelevisionRepository
.ToListAsync(); .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() public async Task<List<Season>> GetAllSeasons()
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

46
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -1,5 +1,7 @@
using ErsatzTV.Infrastructure.Plex.Models; using System.Collections.Specialized;
using ErsatzTV.Infrastructure.Plex.Models;
using Refit; using Refit;
using CollectionFormat = Refit.CollectionFormat;
namespace ErsatzTV.Infrastructure.Plex; namespace ErsatzTV.Infrastructure.Plex;
@ -73,6 +75,48 @@ public interface IPlexServerApi
[Query] [AliasAs("X-Plex-Token")] [Query] [AliasAs("X-Plex-Token")]
string 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")] [Get("/library/metadata/{key}?includeChapters=1")]
[Headers("Accept: text/xml")] [Headers("Accept: text/xml")]
public Task<PlexXmlVideoMetadataResponseContainer> public Task<PlexXmlVideoMetadataResponseContainer>

18
ErsatzTV.Infrastructure/Plex/Models/PlexTagMetadataResponse.cs

@ -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; }
}

9
ErsatzTV.Infrastructure/Plex/NetworkFilter.cs

@ -0,0 +1,9 @@
using Refit;
namespace ErsatzTV.Infrastructure.Plex;
public class NetworkFilter
{
[AliasAs("network")]
public string Network { get; set; }
}

75
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -1,4 +1,6 @@
using System.Collections.Specialized;
using System.Globalization; using System.Globalization;
using System.Web;
using System.Xml.Serialization; using System.Xml.Serialization;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; 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>( private static async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryContents<TItem>(
PlexConnection connection, PlexConnection connection,
Func<IPlexServerApi, Task<PlexXmlMediaContainerStatsResponse>> countItems, 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) private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{ {
PlexMediaResponse<PlexPartResponse> media = response.Media PlexMediaResponse<PlexPartResponse> media = response.Media

12
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -50,7 +50,7 @@ public class ElasticSearchIndex : ISearchIndex
return exists.IsValidResponse; return exists.IsValidResponse;
} }
public int Version => 45; public int Version => 47;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -236,6 +236,7 @@ public class ElasticSearchIndex : ISearchIndex
.Text(t => t.Tag, t => t.Store(false)) .Text(t => t.Tag, t => t.Store(false))
.Keyword(t => t.TagFull, t => t.Store(false)) .Keyword(t => t.TagFull, t => t.Store(false))
.Text(t => t.Studio, 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.Actor, t => t.Store(false))
.Text(t => t.Director, t => t.Store(false)) .Text(t => t.Director, t => t.Store(false))
.Text(t => t.Writer, 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.ShowGenre, t => t.Store(false))
.Text(t => t.ShowTag, t => t.Store(false)) .Text(t => t.ShowTag, t => t.Store(false))
.Text(t => t.ShowStudio, 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)) .Keyword(t => t.ShowContentRating, t => t.Store(false))
.Text(t => t.Style, t => t.Store(false)) .Text(t => t.Style, t => t.Store(false))
.Text(t => t.Mood, t => t.Store(false)) .Text(t => t.Mood, t => t.Store(false))
@ -372,9 +374,10 @@ public class ElasticSearchIndex : ISearchIndex
AddedDate = GetAddedDate(metadata.DateAdded), AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty, Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(), Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Map(t => t.Name).ToList(), Tag = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name).ToList(),
TagFull = metadata.Tags.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(), 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(), Actor = metadata.Actors.Map(a => a.Name).ToList(),
TraktList = show.TraktListItems.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture)) TraktList = show.TraktListItems.Map(t => t.TraktList.TraktId.ToString(CultureInfo.InvariantCulture))
.ToList() .ToList()
@ -628,8 +631,9 @@ public class ElasticSearchIndex : ISearchIndex
{ {
doc.ShowTitle = showMetadata.Title; doc.ShowTitle = showMetadata.Title;
doc.ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList(); 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.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); doc.ShowContentRating = GetContentRatings(showMetadata.ContentRating);
} }

24
ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs

@ -42,6 +42,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
internal const string TitleAndYearField = "title_and_year"; internal const string TitleAndYearField = "title_and_year";
internal const string JumpLetterField = "jump_letter"; internal const string JumpLetterField = "jump_letter";
internal const string StudioField = "studio"; internal const string StudioField = "studio";
internal const string NetworkField = "network";
internal const string LanguageField = "language"; internal const string LanguageField = "language";
internal const string LanguageTagField = "language_tag"; internal const string LanguageTagField = "language_tag";
internal const string SubLanguageField = "sub_language"; internal const string SubLanguageField = "sub_language";
@ -61,6 +62,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
internal const string ShowGenreField = "show_genre"; internal const string ShowGenreField = "show_genre";
internal const string ShowTagField = "show_tag"; internal const string ShowTagField = "show_tag";
internal const string ShowStudioField = "show_studio"; internal const string ShowStudioField = "show_studio";
internal const string ShowNetworkField = "show_network";
internal const string ShowContentRatingField = "show_content_rating"; internal const string ShowContentRatingField = "show_content_rating";
internal const string MetadataKindField = "metadata_kind"; internal const string MetadataKindField = "metadata_kind";
internal const string VideoCodecField = "video_codec"; internal const string VideoCodecField = "video_codec";
@ -114,7 +116,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
return Task.FromResult(directoryExists && fileExists); return Task.FromResult(directoryExists && fileExists);
} }
public int Version => 46; public int Version => 47;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -654,8 +656,15 @@ public sealed class LuceneSearchIndex : ISearchIndex
foreach (Tag tag in metadata.Tags) foreach (Tag tag in metadata.Tags)
{ {
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); if (tag.ExternalTypeId == Tag.PlexNetworkTypeId)
doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO)); {
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) foreach (Studio studio in metadata.Studios)
@ -1026,7 +1035,14 @@ public sealed class LuceneSearchIndex : ISearchIndex
foreach (Tag tag in showMetadata.Tags) 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) foreach (Studio studio in showMetadata.Studios)

6
ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs

@ -91,6 +91,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem
[JsonPropertyName(LuceneSearchIndex.StudioField)] [JsonPropertyName(LuceneSearchIndex.StudioField)]
public List<string> Studio { get; set; } public List<string> Studio { get; set; }
[JsonPropertyName(LuceneSearchIndex.NetworkField)]
public List<string> Network { get; set; }
[JsonPropertyName(LuceneSearchIndex.ArtistField)] [JsonPropertyName(LuceneSearchIndex.ArtistField)]
public List<string> Artist { get; set; } public List<string> Artist { get; set; }
@ -124,6 +127,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem
[JsonPropertyName(LuceneSearchIndex.ShowStudioField)] [JsonPropertyName(LuceneSearchIndex.ShowStudioField)]
public List<string> ShowStudio { get; set; } public List<string> ShowStudio { get; set; }
[JsonPropertyName(LuceneSearchIndex.ShowNetworkField)]
public List<string> ShowNetwork { get; set; }
[JsonPropertyName(LuceneSearchIndex.ShowContentRatingField)] [JsonPropertyName(LuceneSearchIndex.ShowContentRatingField)]
public List<string> ShowContentRating { get; set; } public List<string> ShowContentRating { get; set; }

5
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexNetworks.cs

@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Scanner.Application.Plex;
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;

126
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexNetworksHandler.cs

@ -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; }
}
}

99
ErsatzTV.Scanner/Core/Plex/PlexNetworkScanner.cs

@ -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);
}
}
}

1
ErsatzTV.Scanner/Program.cs

@ -202,6 +202,7 @@ public class Program
services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>(); services.AddScoped<IPlexOtherVideoLibraryScanner, PlexOtherVideoLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>(); services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>(); services.AddScoped<IPlexCollectionScanner, PlexCollectionScanner>();
services.AddScoped<IPlexNetworkScanner, PlexNetworkScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>(); services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>(); services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>(); services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();

23
ErsatzTV.Scanner/Worker.cs

@ -79,6 +79,10 @@ public class Worker : BackgroundService
scanPlexCollectionsCommand.Arguments.Add(mediaSourceIdArgument); scanPlexCollectionsCommand.Arguments.Add(mediaSourceIdArgument);
scanPlexCollectionsCommand.Options.Add(forceOption); 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"); var scanEmbyCommand = new Command("scan-emby", "Scan an Emby library");
scanEmbyCommand.Arguments.Add(libraryIdArgument); scanEmbyCommand.Arguments.Add(libraryIdArgument);
scanEmbyCommand.Options.Add(forceOption); 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( scanEmbyCommand.SetAction(
async (parseResult, token) => async (parseResult, token) =>
{ {
@ -230,6 +252,7 @@ public class Worker : BackgroundService
rootCommand.Subcommands.Add(scanLocalCommand); rootCommand.Subcommands.Add(scanLocalCommand);
rootCommand.Subcommands.Add(scanPlexCommand); rootCommand.Subcommands.Add(scanPlexCommand);
rootCommand.Subcommands.Add(scanPlexCollectionsCommand); rootCommand.Subcommands.Add(scanPlexCollectionsCommand);
rootCommand.Subcommands.Add(scanPlexNetworksCommand);
rootCommand.Subcommands.Add(scanEmbyCommand); rootCommand.Subcommands.Add(scanEmbyCommand);
rootCommand.Subcommands.Add(scanEmbyCollectionsCommand); rootCommand.Subcommands.Add(scanEmbyCollectionsCommand);
rootCommand.Subcommands.Add(scanJellyfinCommand); rootCommand.Subcommands.Add(scanJellyfinCommand);

1
ErsatzTV/Pages/Libraries.razor

@ -180,6 +180,7 @@
case PlexLibraryViewModel: case PlexLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), _cts.Token); await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token); await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), _cts.Token);
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(library.Id, true), _cts.Token);
break; break;
case JellyfinLibraryViewModel: case JellyfinLibraryViewModel:
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token); await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), _cts.Token);

45
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -116,6 +116,18 @@
} }
</div> </div>
} }
@if (_sortedNetworks.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Networks:&nbsp;</MudText>
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
@foreach (string network in _sortedNetworks.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"network:""{network.ToLowerInvariant()}""".GetRelativeSearchQuery())">@network</MudLink>
}
</div>
}
@if (_sortedGenres.Any()) @if (_sortedGenres.Any())
{ {
<div style="display: flex; flex-direction: row; flex-wrap: wrap"> <div style="display: flex; flex-direction: row; flex-wrap: wrap">
@ -176,11 +188,12 @@
public int ShowId { get; set; } public int ShowId { get; set; }
private TelevisionShowViewModel _show; private TelevisionShowViewModel _show;
private List<string> _sortedContentRatings = new(); private List<string> _sortedContentRatings = [];
private List<CultureInfo> _sortedLanguages = new(); private List<CultureInfo> _sortedLanguages = [];
private List<string> _sortedStudios = new(); private List<string> _sortedStudios = [];
private List<string> _sortedGenres = new(); private List<string> _sortedNetworks = [];
private List<string> _sortedTags = new(); private List<string> _sortedGenres = [];
private List<string> _sortedTags = [];
private int _pageSize => 100; private int _pageSize => 100;
private readonly int _pageNumber = 1; private readonly int _pageNumber = 1;
@ -197,17 +210,17 @@
private async Task RefreshData() private async Task RefreshData()
{ {
await Mediator.Send(new GetTelevisionShowById(ShowId), _cts.Token) Option<TelevisionShowViewModel> maybeShow = await Mediator.Send(new GetTelevisionShowById(ShowId), _cts.Token);
.IfSomeAsync( foreach (TelevisionShowViewModel show in maybeShow)
vm => {
{ _show = show;
_show = vm; _sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList(); _sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList(); _sortedStudios = _show.Studios.OrderBy(s => s).ToList();
_sortedStudios = _show.Studios.OrderBy(s => s).ToList(); _sortedGenres = _show.Genres.OrderBy(g => g).ToList();
_sortedGenres = _show.Genres.OrderBy(g => g).ToList(); _sortedTags = _show.Tags.OrderBy(t => t).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); _data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize), _cts.Token);
} }

37
ErsatzTV/Services/ScannerService.cs

@ -57,6 +57,9 @@ public class ScannerService : BackgroundService
case SynchronizePlexCollections synchronizePlexCollections: case SynchronizePlexCollections synchronizePlexCollections:
requestTask = SynchronizePlexCollections(synchronizePlexCollections, stoppingToken); requestTask = SynchronizePlexCollections(synchronizePlexCollections, stoppingToken);
break; break;
case SynchronizePlexNetworks synchronizePlexNetworks:
requestTask = SynchronizePlexNetworks(synchronizePlexNetworks, stoppingToken);
break;
case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries: case SynchronizeJellyfinLibraries synchronizeJellyfinLibraries:
requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, stoppingToken); requestTask = SynchronizeLibraries(synchronizeJellyfinLibraries, stoppingToken);
break; 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) private async Task SynchronizeLibraries(SynchronizeJellyfinLibraries request, CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();

7
ErsatzTV/Services/SchedulerService.cs

@ -247,6 +247,13 @@ public class SchedulerService : BackgroundService
await _scannerWorkerChannel.WriteAsync( await _scannerWorkerChannel.WriteAsync(
new SynchronizePlexLibraryByIdIfNeeded(library.Id), new SynchronizePlexLibraryByIdIfNeeded(library.Id),
cancellationToken); cancellationToken);
if (library.MediaKind is LibraryMediaKind.Shows)
{
await _scannerWorkerChannel.WriteAsync(
new SynchronizePlexNetworks(library.Id, false),
cancellationToken);
}
} }
} }

2
ErsatzTV/Startup.cs

@ -480,7 +480,7 @@ public class Startup
app.UseCors("AllowAll"); app.UseCors("AllowAll");
app.UseForwardedHeaders(); app.UseForwardedHeaders();
// app.UseHttpLogging(); //app.UseHttpLogging();
app.UseSerilogRequestLogging( app.UseSerilogRequestLogging(
options => options =>
{ {

Loading…
Cancel
Save