Browse Source

fix: jellyfin external subtitles (#2972)

pull/2973/head
Jason Dove 1 month ago committed by GitHub
parent
commit
f737de89aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 26
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedSubtitlesHandler.cs
  3. 7
      ErsatzTV.Core/Domain/Metadata/SidecarSubtitleIdentity.cs
  4. 6
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  5. 7060
      ErsatzTV.Infrastructure.MySql/Migrations/20260807010024_Fix_MediaServerExternalSubtitles.Designer.cs
  6. 62
      ErsatzTV.Infrastructure.MySql/Migrations/20260807010024_Fix_MediaServerExternalSubtitles.cs
  7. 6887
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260807005942_Fix_MediaServerExternalSubtitles.Designer.cs
  8. 62
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260807005942_Fix_MediaServerExternalSubtitles.cs
  9. 15
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  10. 6
      ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs
  11. 6
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  12. 6
      ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
  13. 6
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  14. 6
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  15. 6
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  16. 6
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

4
CHANGELOG.md

@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix regression from `v26.6.0` that caused external (sidecar) subtitles from Jellyfin and Emby to go missing
- All Jellyfin external subtitles were deleted by hourly maintenance, so they were missing from **Troubleshooting** > **Playback** and were never burned in
- Jellyfin and Emby items with multiple external subtitles would keep only one of them after a scan
- External subtitles will be restored automatically the next time each Jellyfin or Emby library is scanned
- Fix regression from `v26.6.0` that broke `MPEG-TS` channels on Windows when the channel name or the ffmpeg path contains non-english characters (like `Télévision`)
- Affected channels would connect but never send any data
- `MPEG-TS (Legacy)` and channel preview were not affected

26
ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedSubtitlesHandler.cs

@ -26,10 +26,36 @@ public class DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbConte @@ -26,10 +26,36 @@ public class DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbConte
AND S.ShowMetadataId IS NULL AND S.SongMetadataId IS NULL
""");
// only local sidecars need a path; media server sidecars are fetched by stream index
IEnumerable<int> toDeleteExternal = await dbContext.Connection.QueryAsync<int>(
"""
SELECT S.Id FROM Subtitle S
WHERE S.SubtitleKind = 1 AND (S.Path IS NULL OR S.Path = '')
AND S.Id IN (
SELECT S2.Id FROM Subtitle S2
INNER JOIN MovieMetadata MM ON MM.Id = S2.MovieMetadataId
INNER JOIN MediaItem MI ON MI.Id = MM.MovieId
INNER JOIN LibraryPath LP ON LP.Id = MI.LibraryPathId
INNER JOIN LocalLibrary LL ON LL.Id = LP.LibraryId
UNION
SELECT S2.Id FROM Subtitle S2
INNER JOIN EpisodeMetadata EM ON EM.Id = S2.EpisodeMetadataId
INNER JOIN MediaItem MI ON MI.Id = EM.EpisodeId
INNER JOIN LibraryPath LP ON LP.Id = MI.LibraryPathId
INNER JOIN LocalLibrary LL ON LL.Id = LP.LibraryId
UNION
SELECT S2.Id FROM Subtitle S2
INNER JOIN MusicVideoMetadata MVM ON MVM.Id = S2.MusicVideoMetadataId
INNER JOIN MediaItem MI ON MI.Id = MVM.MusicVideoId
INNER JOIN LibraryPath LP ON LP.Id = MI.LibraryPathId
INNER JOIN LocalLibrary LL ON LL.Id = LP.LibraryId
UNION
SELECT S2.Id FROM Subtitle S2
INNER JOIN OtherVideoMetadata OVM ON OVM.Id = S2.OtherVideoMetadataId
INNER JOIN MediaItem MI ON MI.Id = OVM.OtherVideoId
INNER JOIN LibraryPath LP ON LP.Id = MI.LibraryPathId
INNER JOIN LocalLibrary LL ON LL.Id = LP.LibraryId
)
""");
foreach (int id in toDelete.Concat(toDeleteExternal).Distinct())

7
ErsatzTV.Core/Domain/Metadata/SidecarSubtitleIdentity.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum SidecarSubtitleIdentity
{
FileName = 0,
StreamIndex = 1
}

6
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -47,6 +47,10 @@ public interface IMetadataRepository @@ -47,6 +47,10 @@ public interface IMetadataRepository
Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer);
Task<bool> UpdateSubtitles(Domain.Metadata metadata, List<Subtitle> subtitles, CancellationToken cancellationToken);
Task<bool> UpdateSubtitles(
Domain.Metadata metadata,
List<Subtitle> subtitles,
SidecarSubtitleIdentity sidecarIdentity,
CancellationToken cancellationToken);
Task<bool> UpdateChapters(MediaVersion version, List<MediaChapter> chapters, CancellationToken cancellationToken);
}

7060
ErsatzTV.Infrastructure.MySql/Migrations/20260807010024_Fix_MediaServerExternalSubtitles.Designer.cs generated

File diff suppressed because it is too large Load Diff

62
ErsatzTV.Infrastructure.MySql/Migrations/20260807010024_Fix_MediaServerExternalSubtitles.cs

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Fix_MediaServerExternalSubtitles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// force a re-scan of media server items that have external subtitles; those subtitles were either
// deleted outright (jellyfin sends no path) or collapsed into a single row (jellyfin and emby)
migrationBuilder.Sql(
"""
UPDATE JellyfinEpisode SET Etag = NULL
WHERE Id IN (
SELECT mv.EpisodeId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.EpisodeId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE JellyfinMovie SET Etag = NULL
WHERE Id IN (
SELECT mv.MovieId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.MovieId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE EmbyEpisode SET Etag = NULL
WHERE Id IN (
SELECT mv.EpisodeId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.EpisodeId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE EmbyMovie SET Etag = NULL
WHERE Id IN (
SELECT mv.MovieId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.MovieId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6887
ErsatzTV.Infrastructure.Sqlite/Migrations/20260807005942_Fix_MediaServerExternalSubtitles.Designer.cs generated

File diff suppressed because it is too large Load Diff

62
ErsatzTV.Infrastructure.Sqlite/Migrations/20260807005942_Fix_MediaServerExternalSubtitles.cs

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Fix_MediaServerExternalSubtitles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// force a re-scan of media server items that have external subtitles; those subtitles were either
// deleted outright (jellyfin sends no path) or collapsed into a single row (jellyfin and emby)
migrationBuilder.Sql(
"""
UPDATE JellyfinEpisode SET Etag = NULL
WHERE Id IN (
SELECT mv.EpisodeId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.EpisodeId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE JellyfinMovie SET Etag = NULL
WHERE Id IN (
SELECT mv.MovieId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.MovieId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE EmbyEpisode SET Etag = NULL
WHERE Id IN (
SELECT mv.EpisodeId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.EpisodeId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
migrationBuilder.Sql(
"""
UPDATE EmbyMovie SET Etag = NULL
WHERE Id IN (
SELECT mv.MovieId FROM MediaVersion mv
INNER JOIN MediaStream ms ON ms.MediaVersionId = mv.Id
WHERE mv.MovieId IS NOT NULL AND ms.MediaStreamKind = 5
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

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

@ -530,10 +530,11 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -530,10 +530,11 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
public async Task<bool> UpdateSubtitles(
Core.Domain.Metadata metadata,
List<Subtitle> subtitles,
SidecarSubtitleIdentity sidecarIdentity,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await UpdateSubtitles(dbContext, metadata, subtitles, cancellationToken);
return await UpdateSubtitles(dbContext, metadata, subtitles, sidecarIdentity, cancellationToken);
}
public async Task<bool> UpdateChapters(
@ -627,6 +628,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -627,6 +628,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
TvContext dbContext,
Core.Domain.Metadata metadata,
List<Subtitle> subtitles,
SidecarSubtitleIdentity sidecarIdentity,
CancellationToken cancellationToken)
{
// _logger.LogDebug(
@ -675,7 +677,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -675,7 +677,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
existing,
incoming: subtitles.Filter(s => s.SubtitleKind is SubtitleKind.Sidecar),
SubtitleKind.Sidecar,
keyOf: s => $"file:{Path.GetFileName(s.Path)?.ToLowerInvariant()}",
keyOf: SidecarKey(sidecarIdentity),
applyUpdate: ApplySidecarUpdate,
dbContext);
@ -776,6 +778,15 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -776,6 +778,15 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
existingSubtitle.Title = incomingSubtitle.Title;
}
// media servers give us a stream index but often no usable path (jellyfin sends none at all, emby sends the
// same media source id for every stream), so keying those on file name collapses them all into one subtitle
private static Func<Subtitle, string> SidecarKey(SidecarSubtitleIdentity identity) =>
identity switch
{
SidecarSubtitleIdentity.StreamIndex => s => $"idx:{s.StreamIndex}",
_ => s => $"file:{Path.GetFileName(s.Path)?.ToLowerInvariant()}"
};
private static void ApplySidecarUpdate(Subtitle existingSubtitle, Subtitle incomingSubtitle)
{
existingSubtitle.Default = incomingSubtitle.Default;

6
ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs

@ -87,7 +87,11 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider @@ -87,7 +87,11 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList();
string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path);
subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath));
bool updateResult = await _metadataRepository.UpdateSubtitles(metadata, subtitles, cancellationToken);
bool updateResult = await _metadataRepository.UpdateSubtitles(
metadata,
subtitles,
SidecarSubtitleIdentity.FileName,
cancellationToken);
if (!updateResult)
{
_logger.LogError("Failed to save {Count} subtitles to database", subtitles.Count);

6
ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -455,7 +455,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -455,7 +455,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
.Map(Subtitle.FromMediaStream)
.ToList();
if (await _metadataRepository.UpdateSubtitles(metadata, subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
metadata,
subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
return existing;
}

6
ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs

@ -464,7 +464,11 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters, @@ -464,7 +464,11 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
.Map(Subtitle.FromMediaStream)
.ToList();
if (await _metadataRepository.UpdateSubtitles(metadata, subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
metadata,
subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
return existing;
}

6
ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -851,7 +851,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters, @@ -851,7 +851,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
.Map(Subtitle.FromMediaStream)
.ToList();
if (await _metadataRepository.UpdateSubtitles(metadata, subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
metadata,
subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
return existing;
}

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

@ -345,7 +345,11 @@ public class PlexMovieLibraryScanner : @@ -345,7 +345,11 @@ public class PlexMovieLibraryScanner :
}
}
if (await _metadataRepository.UpdateSubtitles(existingMetadata, fullMetadata.Subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
existingMetadata,
fullMetadata.Subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
result.IsUpdated = true;
}

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

@ -347,7 +347,11 @@ public class PlexOtherVideoLibraryScanner : @@ -347,7 +347,11 @@ public class PlexOtherVideoLibraryScanner :
}
}
if (await _metadataRepository.UpdateSubtitles(existingMetadata, fullMetadata.Subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
existingMetadata,
fullMetadata.Subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
result.IsUpdated = true;
}

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

@ -692,7 +692,11 @@ public partial class PlexTelevisionLibraryScanner : @@ -692,7 +692,11 @@ public partial class PlexTelevisionLibraryScanner :
result.IsUpdated = true;
}
if (await _metadataRepository.UpdateSubtitles(existingMetadata, fullMetadata.Subtitles, cancellationToken))
if (await _metadataRepository.UpdateSubtitles(
existingMetadata,
fullMetadata.Subtitles,
SidecarSubtitleIdentity.StreamIndex,
cancellationToken))
{
result.IsUpdated = true;
}

Loading…
Cancel
Save