Browse Source

fix: plex other video tags (#3015)

pull/3016/head
Jason Dove 5 days ago committed by GitHub
parent
commit
4fffa955c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 7072
      ErsatzTV.Infrastructure.MySql/Migrations/20260911153628_Reset_PlexOtherVideoFolderTags.Designer.cs
  3. 24
      ErsatzTV.Infrastructure.MySql/Migrations/20260911153628_Reset_PlexOtherVideoFolderTags.cs
  4. 6899
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260911153541_Reset_PlexOtherVideoFolderTags.Designer.cs
  5. 24
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260911153541_Reset_PlexOtherVideoFolderTags.cs
  6. 49
      ErsatzTV.Infrastructure.Tests/Plex/PlexServerApiClientTests.cs
  7. 78
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

1
CHANGELOG.md

@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix `/api/sessions` response when channels use Next streaming engine
- Save and restore sequential schedule mid-roll, post-roll, and graphics state between builds
- Fix subtitle playback with Plex other video libraries
- Fix Plex other video tag generation when Plex server and ETV server use different path separators (i.e. Windows and Linux)
## [26.9.0] - 2026-09-06
### Fixed

7072
ErsatzTV.Infrastructure.MySql/Migrations/20260911153628_Reset_PlexOtherVideoFolderTags.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure.MySql/Migrations/20260911153628_Reset_PlexOtherVideoFolderTags.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Reset_PlexOtherVideoFolderTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE PlexOtherVideo SET Etag = NULL;");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '1970-01-01 00:00:00'
WHERE MediaKind = 4 AND Id IN (SELECT Id FROM PlexLibrary);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6899
ErsatzTV.Infrastructure.Sqlite/Migrations/20260911153541_Reset_PlexOtherVideoFolderTags.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure.Sqlite/Migrations/20260911153541_Reset_PlexOtherVideoFolderTags.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Reset_PlexOtherVideoFolderTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE PlexOtherVideo SET Etag = NULL;");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '1970-01-01 00:00:00'
WHERE MediaKind = 4 AND Id IN (SELECT Id FROM PlexLibrary);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

49
ErsatzTV.Infrastructure.Tests/Plex/PlexServerApiClientTests.cs

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
using ErsatzTV.Infrastructure.Plex;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Plex;
[TestFixture]
public class PlexServerApiClientTests
{
[TestCase(@"C:\media\other\Travel\Summer\clip.mkv", @"C:\media\other")]
[TestCase("/media/other/Travel/Summer/clip.mkv", "/media/other")]
[TestCase(@"\\server\media\other\Travel\Summer\clip.mkv", @"\\server\media\other")]
[TestCase(@"C:\media\other/Travel\Summer/clip.mkv", "C:/media/other/")]
[TestCase("/media/other/Travel/Summer/clip.mkv", "/media/other/")]
[TestCase(@"C:\media\other\Travel\Summer\clip.mkv", @"c:\MEDIA\OTHER\")]
public void FolderTags_Should_Include_Library_And_Each_Subfolder(string file, string libraryPath)
{
PlexServerApiClient.GetFolderTags(file, [libraryPath])
.ShouldBe(["other", "Travel", "Summer"]);
}
[TestCase("/media/other/clip.mkv", "/media/other", new[] { "other" })]
[TestCase(@"C:\media\other\clip.mkv", @"C:\media\other", new[] { "other" })]
[TestCase("/Travel/clip.mkv", "/", new[] { "Travel" })]
[TestCase(@"C:\Travel\clip.mkv", @"C:\", new[] { "Travel" })]
[TestCase("/clip.mkv", "/", new string[0])]
[TestCase(@"C:\clip.mkv", @"C:\", new string[0])]
[TestCase("/media/other-extra/clip.mkv", "/media/other", new string[0])]
[TestCase(@"C:\media\other-extra\clip.mkv", @"C:\media\other", new string[0])]
[TestCase("clip.mkv", "/media/other", new string[0])]
[TestCase("", "/media/other", new string[0])]
[TestCase(null, "/media/other", new string[0])]
public void FolderTags_Should_Handle_Roots_And_Require_Folder_Boundaries(
string? file,
string libraryPath,
string[] expected)
{
PlexServerApiClient.GetFolderTags(file!, [libraryPath]).ShouldBe(expected);
}
[Test]
public void FolderTags_Should_Use_First_Matching_Library_Path()
{
PlexServerApiClient.GetFolderTags(
"/media/other-extra/Travel/clip.mkv",
["", "/media/other", "/media/other-extra", "/media/other-extra/Travel"])
.ShouldBe(["other-extra", "Travel"]);
}
}

78
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -1311,40 +1311,14 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> @@ -1311,40 +1311,14 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient>
.MaxBy(media => media.Id);
PlexXmlPartResponse part = media.Part.Head();
string folder = Path.GetDirectoryName(part.File);
IEnumerable<string> libraryPaths = library.Paths
.HeadOrNone()
.Map(p => p.Path)
.Map(JsonConvert.DeserializeObject<LibraryPaths>)
.Map(lp => lp.Paths)
.Flatten();
if (!string.IsNullOrWhiteSpace(folder))
{
IEnumerable<string> libraryPaths = library.Paths
.HeadOrNone()
.Map(p => p.Path)
.Map(JsonConvert.DeserializeObject<LibraryPaths>)
.Map(lp => lp.Paths)
.Flatten();
// check each library path from plex
foreach (string libraryPath in libraryPaths)
{
// if the media file belongs to this library path
if (folder.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
{
// try to get a parent directory of the library path
string parent = Optional(Directory.GetParent(libraryPath)).Match(
di => di.FullName,
() => libraryPath);
// get all folders between parent and media file
string diff = Path.GetRelativePath(parent, folder);
// each folder becomes a tag
IEnumerable<Tag> tags = diff.Split(Path.DirectorySeparatorChar)
.Map(t => new Tag { Name = t });
metadata.Tags.AddRange(tags);
break;
}
}
}
metadata.Tags.AddRange(GetFolderTags(part.File, libraryPaths).Map(t => new Tag { Name = t }));
}
else
{
@ -1405,6 +1379,44 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient> @@ -1405,6 +1379,44 @@ public class PlexServerApiClient(PlexEtag plexEtag, ILogger<PlexServerApiClient>
return metadata;
}
internal static IEnumerable<string> GetFolderTags(string file, IEnumerable<string> libraryPaths)
{
if (string.IsNullOrWhiteSpace(file))
{
return [];
}
// plex paths belong to the server, so do not interpret them with host path apis
string normalizedFile = file.Replace('\\', '/');
int separator = normalizedFile.LastIndexOf('/');
if (separator < 0)
{
return [];
}
string folder = normalizedFile[..separator];
foreach (string libraryPath in libraryPaths)
{
if (string.IsNullOrWhiteSpace(libraryPath))
{
continue;
}
string root = libraryPath.Replace('\\', '/').TrimEnd('/');
if (!folder.Equals(root, StringComparison.OrdinalIgnoreCase) &&
!folder.StartsWith(root + "/", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// include the library folder itself; never a filesystem root or drive name
int start = root.Length == 2 && root[1] == ':' ? root.Length : root.LastIndexOf('/') + 1;
return folder[start..].Split('/', StringSplitOptions.RemoveEmptyEntries);
}
return [];
}
private Option<string> NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show", StringComparison.OrdinalIgnoreCase) ||

Loading…
Cancel
Save