Browse Source

fix: better handling of local subtitles

pull/2924/head
Jason Dove 2 months ago
parent
commit
41f00b78fc
No known key found for this signature in database
  1. 4
      CHANGELOG.md
  2. 31
      ErsatzTV.Application/Maintenance/Commands/DeleteOrphanedSubtitlesHandler.cs
  3. 7051
      ErsatzTV.Infrastructure.MySql/Migrations/20260616164725_Fix_CleanupLocalSubtitleCollisions.Designer.cs
  4. 116
      ErsatzTV.Infrastructure.MySql/Migrations/20260616164725_Fix_CleanupLocalSubtitleCollisions.cs
  5. 6878
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260616164638_Fix_CleanupLocalSubtitleCollisions.Designer.cs
  6. 108
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260616164638_Fix_CleanupLocalSubtitleCollisions.cs
  7. 182
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

4
CHANGELOG.md

@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Previously, these elements required ffmpeg to be on PATH
- Fix erroneous warning `Unable to locate MPEG-TS Script in folder Default` on installations with case-sensitive file systems
- Improve speed of motion graphics element compositing
- Fix multiple issues with subtitle handling in local libraries which impacted stream selection
- Content with multiple sidecar subtitles would incorrectly have the subtitle metadata merged (like language)
- More rarely, content with an embedded subtitle that has stream index zero would create invalid subtitle records
- Both cases will automatically be cleaned up during the next local library scan
## [26.5.1] - 2026-05-08
### Fixed

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

@ -5,29 +5,34 @@ using Microsoft.EntityFrameworkCore; @@ -5,29 +5,34 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedSubtitlesHandler : IRequestHandler<DeleteOrphanedSubtitles, Either<BaseError, Unit>>
public class DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DeleteOrphanedSubtitles, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteOrphanedSubtitlesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteOrphanedSubtitles request,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
IEnumerable<int> toDelete = await dbContext.Connection.QueryAsync<int>(
@"SELECT S.Id FROM Subtitle S
WHERE S.ArtistMetadataId IS NULL AND S.EpisodeMetadataId IS NULL
AND S.MovieMetadataId IS NULL AND S.MusicVideoMetadataId IS NULL
AND S.OtherVideoMetadataId IS NULL AND S.SeasonMetadataId IS NULL
AND S.ShowMetadataId IS NULL AND S.SongMetadataId IS NULL");
"""
SELECT S.Id FROM Subtitle S
WHERE S.ArtistMetadataId IS NULL AND S.EpisodeMetadataId IS NULL
AND S.ImageMetadataId IS NULL AND S.MovieMetadataId IS NULL
AND S.MusicVideoMetadataId IS NULL AND S.OtherVideoMetadataId IS NULL
AND S.RemoteStreamMetadataId IS NULL AND S.SeasonMetadataId IS NULL
AND S.ShowMetadataId IS NULL AND S.SongMetadataId IS NULL
""");
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 = '')
""");
foreach (int id in toDelete)
foreach (int id in toDelete.Concat(toDeleteExternal).Distinct())
{
await dbContext.Connection.ExecuteAsync("DELETE FROM Subtitle WHERE Id = @Id", new { Id = id });
}

7051
ErsatzTV.Infrastructure.MySql/Migrations/20260616164725_Fix_CleanupLocalSubtitleCollisions.Designer.cs generated

File diff suppressed because it is too large Load Diff

116
ErsatzTV.Infrastructure.MySql/Migrations/20260616164725_Fix_CleanupLocalSubtitleCollisions.cs

@ -0,0 +1,116 @@ @@ -0,0 +1,116 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Fix_CleanupLocalSubtitleCollisions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT LibraryFolderId FROM (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN MovieMetadata m ON m.Id = s.MovieMetadataId
INNER JOIN MediaVersion mv ON mv.MovieId = m.MovieId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.MovieMetadataId = s.MovieMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
) affected
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT LibraryFolderId FROM (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN EpisodeMetadata m ON m.Id = s.EpisodeMetadataId
INNER JOIN MediaVersion mv ON mv.EpisodeId = m.EpisodeId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.EpisodeMetadataId = s.EpisodeMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
) affected
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT LibraryFolderId FROM (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN MusicVideoMetadata m ON m.Id = s.MusicVideoMetadataId
INNER JOIN MediaVersion mv ON mv.MusicVideoId = m.MusicVideoId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.MusicVideoMetadataId = s.MusicVideoMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
) affected
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT LibraryFolderId FROM (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN OtherVideoMetadata m ON m.Id = s.OtherVideoMetadataId
INNER JOIN MediaVersion mv ON mv.OtherVideoId = m.OtherVideoId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.OtherVideoMetadataId = s.OtherVideoMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
) affected
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

6878
ErsatzTV.Infrastructure.Sqlite/Migrations/20260616164638_Fix_CleanupLocalSubtitleCollisions.Designer.cs generated

File diff suppressed because it is too large Load Diff

108
ErsatzTV.Infrastructure.Sqlite/Migrations/20260616164638_Fix_CleanupLocalSubtitleCollisions.cs

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Fix_CleanupLocalSubtitleCollisions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN MovieMetadata m ON m.Id = s.MovieMetadataId
INNER JOIN MediaVersion mv ON mv.MovieId = m.MovieId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.MovieMetadataId = s.MovieMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN EpisodeMetadata m ON m.Id = s.EpisodeMetadataId
INNER JOIN MediaVersion mv ON mv.EpisodeId = m.EpisodeId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.EpisodeMetadataId = s.EpisodeMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN MusicVideoMetadata m ON m.Id = s.MusicVideoMetadataId
INNER JOIN MediaVersion mv ON mv.MusicVideoId = m.MusicVideoId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.MusicVideoMetadataId = s.MusicVideoMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
);
""");
migrationBuilder.Sql(
"""
UPDATE LibraryFolder SET Etag = NULL
WHERE Id IN (
SELECT DISTINCT mf.LibraryFolderId
FROM Subtitle s
INNER JOIN OtherVideoMetadata m ON m.Id = s.OtherVideoMetadataId
INNER JOIN MediaVersion mv ON mv.OtherVideoId = m.OtherVideoId
INNER JOIN MediaFile mf ON mf.MediaVersionId = mv.Id
WHERE mf.LibraryFolderId IS NOT NULL
AND (
(s.SubtitleKind = 1 AND (s.Path IS NULL OR s.Path = ''))
OR EXISTS (
SELECT 1 FROM Subtitle s2
WHERE s2.OtherVideoMetadataId = s.OtherVideoMetadataId
AND s2.StreamIndex = s.StreamIndex
AND s2.Id <> s.Id
)
)
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

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

@ -663,85 +663,129 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -663,85 +663,129 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
foreach (Core.Domain.Metadata existing in maybeMetadata)
{
var toAdd = subtitles.Filter(s => existing.Subtitles.All(es => es.StreamIndex != s.StreamIndex)).ToList();
var toRemove = existing.Subtitles.Filter(es => subtitles.All(s => s.StreamIndex != es.StreamIndex))
.ToList();
var toUpdate = subtitles.Except(toAdd).ToList();
bool changed = MergeSubtitles(
existing,
incoming: subtitles.Filter(s => s.SubtitleKind is SubtitleKind.Embedded),
SubtitleKind.Embedded,
keyOf: s => $"idx:{s.StreamIndex}",
applyUpdate: ApplyEmbeddedUpdate,
dbContext);
changed |= MergeSubtitles(
existing,
incoming: subtitles.Filter(s => s.SubtitleKind is SubtitleKind.Sidecar),
SubtitleKind.Sidecar,
keyOf: s => $"file:{Path.GetFileName(s.Path)?.ToLowerInvariant()}",
applyUpdate: ApplySidecarUpdate,
dbContext);
if (changed)
{
return await dbContext.SaveChangesAsync(cancellationToken) > 0;
}
// _logger.LogDebug(
// "Subtitles to add: {ToAdd}, to remove: {ToRemove}, to update: {ToUpdate}",
// toAdd.Count,
// toRemove.Count,
// toUpdate.Count);
// nothing to do
return true;
}
if (toAdd.Count != 0 || toRemove.Count != 0 || toUpdate.Count != 0)
// no metadata
// _logger.LogDebug("Subtitle update failure due to missing metadata");
return false;
}
private static bool MergeSubtitles(
Core.Domain.Metadata existing,
IEnumerable<Subtitle> incoming,
SubtitleKind kind,
Func<Subtitle, string> keyOf,
Action<Subtitle, Subtitle> applyUpdate,
TvContext dbContext)
{
var incomingList = incoming.ToList();
var existingOfKind = existing.Subtitles
.Filter(s => s.SubtitleKind == kind)
.ToList();
var existingByKey = new Dictionary<string, Subtitle>();
var dupes = new List<Subtitle>();
foreach (var e in existingOfKind)
{
if (!existingByKey.TryAdd(keyOf(e), e))
{
// add
existing.Subtitles.AddRange(toAdd);
dupes.Add(e);
}
}
// remove
existing.Subtitles.RemoveAll(s => toRemove.Contains(s));
var incomingByKey = incomingList.GroupBy(keyOf).ToDictionary(g => g.Key, g => g.First());
// update
foreach (Subtitle incomingSubtitle in toUpdate)
{
Subtitle existingSubtitle =
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
// when the kind, codec, language, or flags change we need to extract again
if (existingSubtitle.IsExtracted)
{
bool differentKind = existingSubtitle.SubtitleKind != incomingSubtitle.SubtitleKind;
bool differentCodec = existingSubtitle.Codec != incomingSubtitle.Codec;
bool differentLanguage = existingSubtitle.Language != incomingSubtitle.Language;
bool differentFlags = existingSubtitle.Default != incomingSubtitle.Default ||
existingSubtitle.Forced != incomingSubtitle.Forced ||
existingSubtitle.SDH != incomingSubtitle.SDH;
if (differentKind || differentCodec || differentLanguage || differentFlags)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
else
{
// only re-extract if path changed
// this probably won't ever happen?
bool differentPath = existingSubtitle.Path != incomingSubtitle.Path;
if (!string.IsNullOrEmpty(incomingSubtitle.Path) && differentPath)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
}
}
existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.SDH = incomingSubtitle.SDH;
existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
existingSubtitle.Title = incomingSubtitle.Title;
dbContext.Entry(existingSubtitle).State = EntityState.Modified;
}
var toAdd = incomingByKey.Values.Where(i => !existingByKey.ContainsKey(keyOf(i))).ToList();
var toRemove = existingOfKind.Where(e => !incomingByKey.ContainsKey(keyOf(e)))
.Concat(dupes).Distinct()
.ToHashSet();
var toUpdate = incomingByKey.Values.Where(i => existingByKey.ContainsKey(keyOf(i))).ToList();
int count = await dbContext.SaveChangesAsync(cancellationToken);
existing.Subtitles.AddRange(toAdd);
existing.Subtitles.RemoveAll(toRemove.Contains);
foreach (var inc in toUpdate)
{
var ex = existingByKey[keyOf(inc)];
applyUpdate(ex, inc);
dbContext.Entry(ex).State = EntityState.Modified;
}
// _logger.LogDebug("Subtitles update changed {Count} records in the db", count);
return toAdd.Count != 0 || toRemove.Count != 0 || toUpdate.Count != 0;
}
return count > 0;
private static void ApplyEmbeddedUpdate(Subtitle existingSubtitle, Subtitle incomingSubtitle)
{
// when the kind, codec, language, or flags change we need to extract again
if (existingSubtitle.IsExtracted)
{
bool differentKind = existingSubtitle.SubtitleKind != incomingSubtitle.SubtitleKind;
bool differentCodec = existingSubtitle.Codec != incomingSubtitle.Codec;
bool differentLanguage = existingSubtitle.Language != incomingSubtitle.Language;
bool differentFlags = existingSubtitle.Default != incomingSubtitle.Default ||
existingSubtitle.Forced != incomingSubtitle.Forced ||
existingSubtitle.SDH != incomingSubtitle.SDH;
if (differentKind || differentCodec || differentLanguage || differentFlags)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
else
{
// only re-extract if path changed
// this probably won't ever happen?
bool differentPath = existingSubtitle.Path != incomingSubtitle.Path;
if (!string.IsNullOrEmpty(incomingSubtitle.Path) && differentPath)
{
existingSubtitle.IsExtracted = false;
existingSubtitle.Path = incomingSubtitle.Path;
}
}
// nothing to do
// _logger.LogDebug("Subtitle update requires no database changes");
return true;
}
// no metadata
// _logger.LogDebug("Subtitle update failure due to missing metadata");
return false;
existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.SDH = incomingSubtitle.SDH;
existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
existingSubtitle.Title = incomingSubtitle.Title;
}
private static void ApplySidecarUpdate(Subtitle existingSubtitle, Subtitle incomingSubtitle)
{
existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.SDH = incomingSubtitle.SDH;
existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
existingSubtitle.Title = incomingSubtitle.Title;
existingSubtitle.Path = incomingSubtitle.Path;
}
}

Loading…
Cancel
Save