Browse Source

fix: tag maintenance (#3001)

pull/3002/head
Jason Dove 2 weeks ago committed by GitHub
parent
commit
9067cde884
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Television/Mapper.cs
  3. 12
      ErsatzTV.Core/Domain/Metadata/Tag.cs
  4. 7066
      ErsatzTV.Infrastructure.MySql/Migrations/20260903214818_Backfill_Tag_ExternalTypeId.Designer.cs
  5. 54
      ErsatzTV.Infrastructure.MySql/Migrations/20260903214818_Backfill_Tag_ExternalTypeId.cs
  6. 6893
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260903214731_Backfill_Tag_ExternalTypeId.Designer.cs
  7. 54
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260903214731_Backfill_Tag_ExternalTypeId.cs
  8. 48
      ErsatzTV.Infrastructure/Data/Repositories/EmbyCollectionRepository.cs
  9. 2
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  10. 4
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  11. 4
      ErsatzTV.Infrastructure/Data/Repositories/ImageRepository.cs
  12. 48
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinCollectionRepository.cs
  13. 2
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  14. 4
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  15. 8
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  16. 4
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  17. 4
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  18. 4
      ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs
  19. 48
      ErsatzTV.Infrastructure/Data/Repositories/PlexCollectionRepository.cs
  20. 11
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  21. 4
      ErsatzTV.Infrastructure/Data/Repositories/RemoteStreamRepository.cs
  22. 4
      ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs
  23. 12
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  24. 21
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  25. 10
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  26. 115
      ErsatzTV.Scanner.Tests/Core/Plex/PlexTagOwnershipTests.cs
  27. 8
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  28. 9
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  29. 9
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  30. 30
      ErsatzTV.Scanner/Core/Plex/PlexTagOwnership.cs
  31. 27
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

4
CHANGELOG.md

@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -24,6 +24,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix bug in XMLTV template for episodes that was breaking thumbnail artwork
- Those with customized `episode.sbntxt` templates will want to make a similar fix
- Disable HDHR endpoints when JWT is used; they never worked in this configuration in the first place
- Fix scanners deleting, duplicating and stranding each other's tags; tags now track which scanner owns them
- Symptoms included a show losing every tag when it changed networks (dropping it from collections and schedules), networks vanishing after a library scan, and labels or collections that were renamed or deleted on the media server never going away in ETV
- To recover a show that already lost its tags: deep scan it from the show page, then use **Deep Scan Collections** on the libraries page; networks return on the next network scan with no action needed
- `<country>` from NFO metadata is now searchable with `country:` instead of `tag:`; deep scan a local library to convert existing items
## [26.8.1] - 2026-08-29
### Security

2
ErsatzTV.Application/Television/Mapper.cs

@ -32,7 +32,7 @@ internal static class Mapper @@ -32,7 +32,7 @@ internal static class Mapper
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([]),
show.ShowMetadata.HeadOrNone().Map(m =>
m.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(g => g.Name).ToList()).IfNone([]),
m.Tags.Where(Tag.IsSearchTag).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([]),

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

@ -5,8 +5,20 @@ public class Tag @@ -5,8 +5,20 @@ public class Tag
public static readonly string PlexNetworkTypeId = "319";
public static readonly string NfoCountryTypeId = "nfo/country";
// several scanners write tags for one metadata row
// each scanner must remove only the rows that it owns
public static readonly string PlexLabelTypeId = "plex/label";
public static readonly string PlexCollectionTypeId = "plex/collection";
public static readonly string EmbyCollectionTypeId = "emby/collection";
public static readonly string JellyfinCollectionTypeId = "jellyfin/collection";
public int Id { get; set; }
public string Name { get; set; }
public string ExternalCollectionId { get; set; }
public string ExternalTypeId { get; set; }
// a tag with a new type id must stay in the tag field, or a smart collection
// that uses tag: loses items
public static bool IsSearchTag(Tag tag) =>
tag.ExternalTypeId != PlexNetworkTypeId && tag.ExternalTypeId != NfoCountryTypeId;
}

7066
ErsatzTV.Infrastructure.MySql/Migrations/20260903214818_Backfill_Tag_ExternalTypeId.Designer.cs generated

File diff suppressed because it is too large Load Diff

54
ErsatzTV.Infrastructure.MySql/Migrations/20260903214818_Backfill_Tag_ExternalTypeId.cs

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Backfill_Tag_ExternalTypeId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// the order is important; each collection scanner takes its own rows first
// a row that still has a collection id after that came from a plex label
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'plex/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT `Key` FROM PlexCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'emby/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT ItemId FROM EmbyCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'jellyfin/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT ItemId FROM JellyfinCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'plex/label'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IS NOT NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = NULL
WHERE ExternalTypeId IN ('plex/label', 'plex/collection', 'emby/collection', 'jellyfin/collection');
""");
}
}
}

6893
ErsatzTV.Infrastructure.Sqlite/Migrations/20260903214731_Backfill_Tag_ExternalTypeId.Designer.cs generated

File diff suppressed because it is too large Load Diff

54
ErsatzTV.Infrastructure.Sqlite/Migrations/20260903214731_Backfill_Tag_ExternalTypeId.cs

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Backfill_Tag_ExternalTypeId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// the order is important; each collection scanner takes its own rows first
// a row that still has a collection id after that came from a plex label
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'plex/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT `Key` FROM PlexCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'emby/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT ItemId FROM EmbyCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'jellyfin/collection'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IN (SELECT ItemId FROM JellyfinCollection);
""");
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = 'plex/label'
WHERE ExternalTypeId IS NULL
AND ExternalCollectionId IS NOT NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE Tag SET ExternalTypeId = NULL
WHERE ExternalTypeId IN ('plex/label', 'plex/collection', 'emby/collection', 'jellyfin/collection');
""");
}
}
}

48
ErsatzTV.Infrastructure/Data/Repositories/EmbyCollectionRepository.cs

@ -32,8 +32,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -32,8 +32,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
@"DELETE FROM Tag WHERE ExternalCollectionId = @ItemId AND ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId });
return await dbContext.SaveChangesAsync() > 0;
}
@ -50,8 +50,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -50,8 +50,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
@"SELECT EM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN EmbyMovie EM on EM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId }));
// shows
result.AddRange(
@ -59,8 +59,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -59,8 +59,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
@"SELECT ES.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN EmbyShow ES on ES.Id = SM.ShowId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId }));
// seasons
result.AddRange(
@ -68,8 +68,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -68,8 +68,8 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
@"SELECT ES.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN EmbySeason ES on ES.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId }));
// episodes
result.AddRange(
@ -77,13 +77,13 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -77,13 +77,13 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
@"SELECT EE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN EmbyEpisode EE on EE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
@"DELETE FROM Tag WHERE ExternalCollectionId = @ItemId AND ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId });
return result;
}
@ -103,10 +103,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -103,10 +103,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
}
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, MovieMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId) AS A",
new { collection.Name, collection.ItemId, MovieId = movieId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId, MovieId = movieId });
return movieId;
case EmbyShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@ -118,10 +118,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -118,10 +118,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
}
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, ShowMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId) AS A",
new { collection.Name, collection.ItemId, ShowId = showId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId, ShowId = showId });
return showId;
case EmbySeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@ -133,10 +133,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -133,10 +133,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
}
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, SeasonMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId) AS A",
new { collection.Name, collection.ItemId, SeasonId = seasonId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId, SeasonId = seasonId });
return seasonId;
case EmbyEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@ -148,10 +148,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository @@ -148,10 +148,10 @@ public class EmbyCollectionRepository : IEmbyCollectionRepository
}
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, EpisodeMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId) AS A",
new { collection.Name, collection.ItemId, EpisodeId = episodeId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.EmbyCollectionTypeId, EpisodeId = episodeId });
return episodeId;
default:
return Option<int>.None;

2
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -280,7 +280,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository @@ -280,7 +280,7 @@ public class EmbyMovieRepository : IEmbyMovieRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);

4
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -554,7 +554,7 @@ public class EmbyTelevisionRepository( @@ -554,7 +554,7 @@ public class EmbyTelevisionRepository(
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);
@ -864,7 +864,7 @@ public class EmbyTelevisionRepository( @@ -864,7 +864,7 @@ public class EmbyTelevisionRepository(
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);

4
ErsatzTV.Infrastructure/Data/Repositories/ImageRepository.cs

@ -104,8 +104,8 @@ public class ImageRepository : IImageRepository @@ -104,8 +104,8 @@ public class ImageRepository : IImageRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ImageMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, ImageMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
}
private async Task<Either<BaseError, MediaItemScanResult<Image>>> AddImage(

48
ErsatzTV.Infrastructure/Data/Repositories/JellyfinCollectionRepository.cs

@ -32,8 +32,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -32,8 +32,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
@"DELETE FROM Tag WHERE ExternalCollectionId = @ItemId AND ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId });
return await dbContext.SaveChangesAsync() > 0;
}
@ -50,8 +50,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -50,8 +50,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
@"SELECT JM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN JellyfinMovie JM on JM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId }));
// shows
result.AddRange(
@ -59,8 +59,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -59,8 +59,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
@"SELECT JS.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN JellyfinShow JS on JS.Id = SM.ShowId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId }));
// seasons
result.AddRange(
@ -68,8 +68,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -68,8 +68,8 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
@"SELECT JS.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN JellyfinSeason JS on JS.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId }));
// episodes
result.AddRange(
@ -77,13 +77,13 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -77,13 +77,13 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
@"SELECT JE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN JellyfinEpisode JE on JE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
WHERE T.ExternalCollectionId = @ItemId AND T.ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
@"DELETE FROM Tag WHERE ExternalCollectionId = @ItemId AND ExternalTypeId = @TagTypeId",
new { collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId });
return result;
}
@ -98,40 +98,40 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository @@ -98,40 +98,40 @@ public class JellyfinCollectionRepository : IJellyfinCollectionRepository
@"SELECT Id FROM JellyfinMovie WHERE ItemId = @ItemId",
new { movie.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, MovieMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId) AS A",
new { collection.Name, collection.ItemId, MovieId = movieId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId, MovieId = movieId });
return movieId;
case JellyfinShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
new { show.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, ShowMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId) AS A",
new { collection.Name, collection.ItemId, ShowId = showId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId, ShowId = showId });
return showId;
case JellyfinSeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
new { season.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, SeasonMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId) AS A",
new { collection.Name, collection.ItemId, SeasonId = seasonId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId, SeasonId = seasonId });
return seasonId;
case JellyfinEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinEpisode WHERE ItemId = @ItemId",
new { episode.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @ItemId, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, EpisodeMetadataId)
SELECT @Name, @ItemId, @TagTypeId, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId) AS A",
new { collection.Name, collection.ItemId, EpisodeId = episodeId });
new { collection.Name, collection.ItemId, TagTypeId = Tag.JellyfinCollectionTypeId, EpisodeId = episodeId });
return episodeId;
default:
return 0;

2
ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs

@ -242,7 +242,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository @@ -242,7 +242,7 @@ public class JellyfinMovieRepository : IJellyfinMovieRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);

4
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -590,7 +590,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -590,7 +590,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);
@ -902,7 +902,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -902,7 +902,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.Filter(g => g.ExternalCollectionId is null && g.ExternalTypeId is null)
.ToList())
{
metadata.Tags.Remove(tag);

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

@ -594,8 +594,12 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -594,8 +594,12 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Tag WHERE Id = @TagId AND ((ExternalCollectionId = @ExternalCollectionId) OR (ExternalCollectionId IS NULL AND @ExternalCollectionId IS NULL))",
new { TagId = tag.Id, tag.ExternalCollectionId })
// the id alone finds the row; the 2 other columns make sure that a scanner
// does not delete a tag that a different scanner owns
@"DELETE FROM Tag WHERE Id = @TagId
AND ((ExternalCollectionId = @ExternalCollectionId) OR (ExternalCollectionId IS NULL AND @ExternalCollectionId IS NULL))
AND ((ExternalTypeId = @ExternalTypeId) OR (ExternalTypeId IS NULL AND @ExternalTypeId IS NULL))",
new { TagId = tag.Id, tag.ExternalCollectionId, tag.ExternalTypeId })
.Map(result => result > 0);
}

4
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -154,8 +154,8 @@ public class MovieRepository : IMovieRepository @@ -154,8 +154,8 @@ public class MovieRepository : IMovieRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, MovieMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, MovieMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(MovieMetadata metadata, Studio studio)

4
ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs

@ -125,8 +125,8 @@ public class MusicVideoRepository : IMusicVideoRepository @@ -125,8 +125,8 @@ public class MusicVideoRepository : IMusicVideoRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, MusicVideoMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, MusicVideoMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio)

4
ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs

@ -118,8 +118,8 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -118,8 +118,8 @@ public class OtherVideoRepository : IOtherVideoRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, OtherVideoMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, OtherVideoMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(OtherVideoMetadata metadata, Studio studio)

48
ErsatzTV.Infrastructure/Data/Repositories/PlexCollectionRepository.cs

@ -32,8 +32,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -32,8 +32,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @Key",
new { collection.Name, collection.Key });
@"DELETE FROM Tag WHERE ExternalCollectionId = @Key AND ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId });
return await dbContext.SaveChangesAsync() > 0;
}
@ -50,8 +50,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -50,8 +50,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository
@"SELECT JM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN PlexMovie JM on JM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
WHERE T.ExternalCollectionId = @Key AND T.ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId }));
// shows
result.AddRange(
@ -59,8 +59,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -59,8 +59,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository
@"SELECT JS.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN PlexShow JS on JS.Id = SM.ShowId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
WHERE T.ExternalCollectionId = @Key AND T.ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId }));
// seasons
result.AddRange(
@ -68,8 +68,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -68,8 +68,8 @@ public class PlexCollectionRepository : IPlexCollectionRepository
@"SELECT JS.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN PlexSeason JS on JS.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
WHERE T.ExternalCollectionId = @Key AND T.ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId }));
// episodes
result.AddRange(
@ -77,13 +77,13 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -77,13 +77,13 @@ public class PlexCollectionRepository : IPlexCollectionRepository
@"SELECT JE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN PlexEpisode JE on JE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @Key",
new { collection.Key }));
WHERE T.ExternalCollectionId = @Key AND T.ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @Key",
new { collection.Name, collection.Key });
@"DELETE FROM Tag WHERE ExternalCollectionId = @Key AND ExternalTypeId = @TagTypeId",
new { collection.Key, TagTypeId = Tag.PlexCollectionTypeId });
return result;
}
@ -98,40 +98,40 @@ public class PlexCollectionRepository : IPlexCollectionRepository @@ -98,40 +98,40 @@ public class PlexCollectionRepository : IPlexCollectionRepository
@"SELECT Id FROM PlexMovie WHERE `Key` = @Key",
new { movie.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @Key, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, MovieMetadataId)
SELECT @Name, @Key, @TagTypeId, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId) AS A",
new { collection.Name, collection.Key, MovieId = movieId });
new { collection.Name, collection.Key, TagTypeId = Tag.PlexCollectionTypeId, MovieId = movieId });
return movieId;
case PlexShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexShow WHERE `Key` = @Key",
new { show.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @Key, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, ShowMetadataId)
SELECT @Name, @Key, @TagTypeId, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId) AS A",
new { collection.Name, collection.Key, ShowId = showId });
new { collection.Name, collection.Key, TagTypeId = Tag.PlexCollectionTypeId, ShowId = showId });
return showId;
case PlexSeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexSeason WHERE `Key` = @Key",
new { season.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @Key, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, SeasonMetadataId)
SELECT @Name, @Key, @TagTypeId, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId) AS A",
new { collection.Name, collection.Key, SeasonId = seasonId });
new { collection.Name, collection.Key, TagTypeId = Tag.PlexCollectionTypeId, SeasonId = seasonId });
return seasonId;
case PlexEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM PlexEpisode WHERE `Key` = @Key",
new { episode.Key });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @Key, Id FROM
@"INSERT INTO Tag (Name, ExternalCollectionId, ExternalTypeId, EpisodeMetadataId)
SELECT @Name, @Key, @TagTypeId, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId) AS A",
new { collection.Name, collection.Key, EpisodeId = episodeId });
new { collection.Name, collection.Key, TagTypeId = Tag.PlexCollectionTypeId, EpisodeId = episodeId });
return episodeId;
default:
return 0;

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

@ -518,8 +518,9 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -518,8 +518,9 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
{
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))
.SelectMany(sm => sm.Tags
.Where(t => t.Name == tag.Tag && t.ExternalTypeId == tagType)
.Select(t => t.Id))
.ToListAsync(cancellationToken);
// delete all tags
@ -560,10 +561,16 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -560,10 +561,16 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
}
int showId = await dbContext.PlexShows
.Where(s => s.LibraryPath.LibraryId == library.Id)
.Where(s => s.Key == show.Key)
.Select(s => s.Id)
.FirstOrDefaultAsync(cancellationToken);
if (showId <= 0)
{
return new PlexShowAddTagResult(Option<int>.None, Option<int>.None);
}
await dbContext.Connection.ExecuteAsync(
new CommandDefinition(
@"INSERT INTO Tag (Name, ExternalTypeId, ShowMetadataId)

4
ErsatzTV.Infrastructure/Data/Repositories/RemoteStreamRepository.cs

@ -109,8 +109,8 @@ public class RemoteStreamRepository( @@ -109,8 +109,8 @@ public class RemoteStreamRepository(
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Connection.ExecuteAsync(
new CommandDefinition(
"INSERT INTO Tag (Name, RemoteStreamMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
parameters: new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId },
"INSERT INTO Tag (Name, RemoteStreamMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
parameters: new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId },
cancellationToken: cancellationToken)).Map(result => result > 0);
}

4
ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs

@ -100,8 +100,8 @@ public class SongRepository : ISongRepository @@ -100,8 +100,8 @@ public class SongRepository : ISongRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, SongMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, SongMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
}
private static async Task<Either<BaseError, MediaItemScanResult<Song>>> AddSong(

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

@ -577,16 +577,16 @@ public class TelevisionRepository : ITelevisionRepository @@ -577,16 +577,16 @@ public class TelevisionRepository : ITelevisionRepository
{
case ShowMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, ShowMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
case SeasonMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, SeasonMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, SeasonMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
case EpisodeMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, EpisodeMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
"INSERT INTO Tag (Name, EpisodeMetadataId, ExternalCollectionId, ExternalTypeId) VALUES (@Name, @MetadataId, @ExternalCollectionId, @ExternalTypeId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId, tag.ExternalTypeId }).Map(result => result > 0);
default:
return false;
}

21
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -710,7 +710,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> @@ -710,7 +710,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient>
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) });
new Tag
{
Name = label.Tag,
ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture),
ExternalTypeId = Tag.PlexLabelTypeId
});
}
if (!string.IsNullOrWhiteSpace(response.Studio))
@ -940,7 +945,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> @@ -940,7 +945,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient>
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) });
new Tag
{
Name = label.Tag,
ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture),
ExternalTypeId = Tag.PlexLabelTypeId
});
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
@ -1344,7 +1354,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> @@ -1344,7 +1354,12 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient>
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(
new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture) });
new Tag
{
Name = label.Tag,
ExternalCollectionId = label.Id.ToString(CultureInfo.InvariantCulture),
ExternalTypeId = Tag.PlexLabelTypeId
});
}
if (!string.IsNullOrWhiteSpace(response.Studio))

10
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -345,9 +345,9 @@ public class ElasticSearchIndex : ISearchIndex @@ -345,9 +345,9 @@ public class ElasticSearchIndex : ISearchIndex
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name)
Tag = metadata.Tags.Where(Tag.IsSearchTag).Map(t => t.Name)
.ToList(),
TagFull = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name)
TagFull = metadata.Tags.Where(Tag.IsSearchTag).Map(t => t.Name)
.ToList(),
Country = metadata.Tags.Where(t => t.ExternalTypeId == Tag.NfoCountryTypeId).Map(t => t.Name)
.ToList(),
@ -407,9 +407,9 @@ public class ElasticSearchIndex : ISearchIndex @@ -407,9 +407,9 @@ public class ElasticSearchIndex : ISearchIndex
AddedDate = GetAddedDate(metadata.DateAdded),
Plot = metadata.Plot ?? string.Empty,
Genre = metadata.Genres.Map(g => g.Name).ToList(),
Tag = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name)
Tag = metadata.Tags.Where(Tag.IsSearchTag).Map(t => t.Name)
.ToList(),
TagFull = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name)
TagFull = metadata.Tags.Where(Tag.IsSearchTag).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)
@ -677,7 +677,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -677,7 +677,7 @@ public class ElasticSearchIndex : ISearchIndex
{
doc.ShowTitle = showMetadata.Title;
doc.ShowGenre = showMetadata.Genres.Map(g => g.Name).ToList();
doc.ShowTag = showMetadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId))
doc.ShowTag = showMetadata.Tags.Where(Tag.IsSearchTag)
.Map(t => t.Name).ToList();
doc.ShowStudio = showMetadata.Studios.Map(s => s.Name).ToList();
doc.ShowNetwork = showMetadata.Tags.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)

115
ErsatzTV.Scanner.Tests/Core/Plex/PlexTagOwnershipTests.cs

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Scanner.Core.Plex;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Scanner.Tests.Core.Plex;
[TestFixture]
public class PlexTagOwnershipTests
{
private static Tag Label(string name, string id) =>
new() { Name = name, ExternalCollectionId = id, ExternalTypeId = Tag.PlexLabelTypeId };
private static Tag Collection(string name, string key) =>
new() { Name = name, ExternalCollectionId = key, ExternalTypeId = Tag.PlexCollectionTypeId };
private static Tag Network(string name) =>
new() { Name = name, ExternalTypeId = Tag.PlexNetworkTypeId };
private static Tag Country(string name) =>
new() { Name = name, ExternalTypeId = Tag.NfoCountryTypeId };
private static Tag Untyped(string name) => new() { Name = name };
[Test]
public void TagsToRemove_Should_Keep_Tags_Of_Other_Scanners()
{
List<Tag> existing =
[
Collection("Marvel", "90210"),
Network("HBO"),
Country("USA")
];
PlexTagOwnership.TagsToRemove(existing, []).ShouldBeEmpty();
}
[Test]
public void TagsToRemove_Should_Remove_A_Label_That_Plex_No_Longer_Has()
{
List<Tag> existing = [Label("4K Remux", "5"), Collection("Marvel", "90210")];
List<Tag> result = PlexTagOwnership.TagsToRemove(existing, []).ToList();
result.Count.ShouldBe(1);
result[0].Name.ShouldBe("4K Remux");
}
[Test]
public void TagsToRemove_Should_Keep_A_Label_That_Plex_Still_Has()
{
List<Tag> existing = [Label("4K Remux", "5")];
List<Tag> incoming = [Label("4K Remux", "5")];
PlexTagOwnership.TagsToRemove(existing, incoming).ShouldBeEmpty();
}
[Test]
public void TagsToRemove_Should_Remove_An_Untyped_Label_From_An_Older_Version()
{
List<Tag> existing = [Untyped("4K Remux")];
List<Tag> incoming = [Label("4K Remux", "5")];
List<Tag> result = PlexTagOwnership.TagsToRemove(existing, incoming).ToList();
result.Count.ShouldBe(1);
result[0].ExternalTypeId.ShouldBeNull();
}
[Test]
public void A_Renamed_Label_Should_Be_Removed_And_Added()
{
List<Tag> existing = [Label("4K Remux", "5")];
List<Tag> incoming = [Label("4K Remaster", "5")];
List<Tag> toRemove = PlexTagOwnership.TagsToRemove(existing, incoming).ToList();
List<Tag> toAdd = PlexTagOwnership.TagsToAdd(existing, incoming).ToList();
toRemove.Count.ShouldBe(1);
toRemove[0].Name.ShouldBe("4K Remux");
toAdd.Count.ShouldBe(1);
toAdd[0].Name.ShouldBe("4K Remaster");
}
[Test]
public void TagsToAdd_Should_Not_Repeat_A_Label_That_Exists()
{
List<Tag> existing = [Label("4K Remux", "5"), Network("HBO")];
List<Tag> incoming = [Label("4K Remux", "5")];
PlexTagOwnership.TagsToAdd(existing, incoming).ShouldBeEmpty();
}
[Test]
public void TagsToAdd_Should_Add_A_Label_That_Shares_A_Name_With_A_Network()
{
List<Tag> existing = [Network("HBO")];
List<Tag> incoming = [Label("HBO", "5")];
List<Tag> result = PlexTagOwnership.TagsToAdd(existing, incoming).ToList();
result.Count.ShouldBe(1);
result[0].ExternalTypeId.ShouldBe(Tag.PlexLabelTypeId);
}
[Test]
public void IsSearchTag_Should_Hold_Labels_And_Collections_But_Not_Networks()
{
Tag.IsSearchTag(Label("4K Remux", "5")).ShouldBeTrue();
Tag.IsSearchTag(Collection("Marvel", "90210")).ShouldBeTrue();
Tag.IsSearchTag(Untyped("local")).ShouldBeTrue();
Tag.IsSearchTag(Network("HBO")).ShouldBeFalse();
Tag.IsSearchTag(Country("USA")).ShouldBeFalse();
}
}

8
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -1636,7 +1636,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1636,7 +1636,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider
}
}
foreach (Tag tag in existing.Tags.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name))
// an older version did not save the type id; the comparison uses the type id,
// so a scan replaces such a row
foreach (Tag tag in existing.Tags
.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name || t2.ExternalTypeId != t.ExternalTypeId))
.ToList())
{
existing.Tags.Remove(tag);
@ -1646,7 +1649,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1646,7 +1649,8 @@ public class LocalMetadataProvider : ILocalMetadataProvider
}
}
foreach (Tag tag in incoming.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
foreach (Tag tag in incoming.Tags
.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name || t2.ExternalTypeId != t.ExternalTypeId))
.ToList())
{
existing.Tags.Add(tag);

9
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -322,10 +322,7 @@ public class PlexMovieLibraryScanner : @@ -322,10 +322,7 @@ public class PlexMovieLibraryScanner :
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToRemove(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
@ -334,9 +331,7 @@ public class PlexMovieLibraryScanner : @@ -334,9 +331,7 @@ public class PlexMovieLibraryScanner :
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToAdd(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Add(tag);
if (await _movieRepository.AddTag(existingMetadata, tag))

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

@ -324,10 +324,7 @@ public class PlexOtherVideoLibraryScanner : @@ -324,10 +324,7 @@ public class PlexOtherVideoLibraryScanner :
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToRemove(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
@ -336,9 +333,7 @@ public class PlexOtherVideoLibraryScanner : @@ -336,9 +333,7 @@ public class PlexOtherVideoLibraryScanner :
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToAdd(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Add(tag);
if (await _otherVideoRepository.AddTag(existingMetadata, tag))

30
ErsatzTV.Scanner/Core/Plex/PlexTagOwnership.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Scanner.Core.Plex;
internal static class PlexTagOwnership
{
// an untyped row is a label that an older version wrote before labels had a type id
public static bool IsLibraryScannerOwned(Tag tag) =>
tag.ExternalTypeId == Tag.PlexLabelTypeId ||
(tag.ExternalTypeId is null && tag.ExternalCollectionId is null);
// a rename keeps the plex id, so the name is part of the identity; without the name
// a renamed label never goes away and never comes back
public static bool IsSameLabel(Tag left, Tag right) =>
left.ExternalCollectionId == right.ExternalCollectionId && left.Name == right.Name;
public static IEnumerable<Tag> TagsToRemove(IEnumerable<Tag> existing, IEnumerable<Tag> incoming)
{
var incomingLabels = incoming.ToList();
return existing
.Filter(IsLibraryScannerOwned)
.Filter(tag => incomingLabels.All(label => !IsSameLabel(tag, label)));
}
public static IEnumerable<Tag> TagsToAdd(IEnumerable<Tag> existing, IEnumerable<Tag> incoming)
{
var existingTags = existing.ToList();
return incoming.Filter(label => existingTags.All(tag => !IsSameLabel(label, tag)));
}
}

27
ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

@ -488,10 +488,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -488,10 +488,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToRemove(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
@ -500,9 +497,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -500,9 +497,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToAdd(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Add(tag);
if (await _televisionRepository.AddTag(existingMetadata, tag))
@ -573,10 +568,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -573,10 +568,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToRemove(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
@ -585,9 +577,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -585,9 +577,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToAdd(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Add(tag);
if (await _televisionRepository.AddTag(existingMetadata, tag))
@ -636,10 +626,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -636,10 +626,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToRemove(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
@ -648,9 +635,7 @@ public partial class PlexTelevisionLibraryScanner : @@ -648,9 +635,7 @@ public partial class PlexTelevisionLibraryScanner :
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Tag tag in PlexTagOwnership.TagsToAdd(existingMetadata.Tags, fullMetadata.Tags).ToList())
{
existingMetadata.Tags.Add(tag);
if (await _televisionRepository.AddTag(existingMetadata, tag))

Loading…
Cancel
Save