Files
ersatztv/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
T
timothy 83cd36e0de test(491): run the dedupe fixture against MySql in CI; correct the collation claim
The dedupe DML had zero automated coverage on MySql: the migrations job only
applies migrations to a fresh EMPTY database, so no dedupe row ever executed
there. Two MySql-only collation defects escaped that gate in this session and
were caught only by hand-run containers.

Parameterize LibraryFolderDedupeMigrationTests over both providers from ONE
fixture body - same seeded rows, same expected survivors - rather than adding a
MySql-only copy that would drift and recreate the gap. Assertions no longer use
WHERE Path = '...', which is itself collation-dependent and would quietly mean
something different per provider; rows are read once and compared ordinally in
memory. A new step in the existing migrations job runs it against that job's
mysql:8.4 service, on a per-test database of its own.

Proven red when the collation is wrong: restoring COLLATE utf8mb4_bin fails the
MySql half with survivors [1,4,5,6,7,9] - the trailing-space sibling deleted -
while SQLite stays green. Proven non-skippable: without
ETV_TEST_MYSQL_CONNECTION the fixture ignores visibly, and with
ETV_REQUIRE_MYSQL_TESTS=1 (which CI sets) that skip becomes a hard failure, so
it cannot pass having connected to nothing. Local runs need no MySql.

Also correct an overstated comment. The schema pins only the utf8mb4 charset,
never a collation, so the effective comparison is the server default: always
case-insensitive, but PAD SPACE only on utf8mb4_general_ci - 8.4's default
utf8mb4_0900_ai_ci is NO PAD, verified on the real column. The migration bug was
independent of that because the old code applied an EXPLICIT utf8mb4_bin, which
is PAD SPACE everywhere; the runtime simply tolerates both.

Refs #488 #308
fix #491
2026-07-25 21:13:31 +02:00

384 lines
17 KiB
C#

using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvContext> dbContextFactory)
: ILibraryRepository
{
public async Task<LibraryPath> Add(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.LibraryPaths.AddAsync(libraryPath);
await dbContext.SaveChangesAsync();
return libraryPath;
}
public async Task<Option<Library>> GetLibrary(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries
.Include(l => l.Paths)
.ThenInclude(p => p.LibraryFolders)
.ThenInclude(lf => lf.ImageFolderDuration)
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional);
}
public async Task<Option<LocalLibrary>> GetLocal(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional);
}
public async Task<Option<int>> GetLibraryIdForPath(int libraryPathId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
int? libraryId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
"SELECT LibraryId FROM LibraryPath WHERE Id = @LibraryPathId",
new { LibraryPathId = libraryPathId });
return Optional(libraryId);
}
public async Task<List<Library>> GetAll()
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries
.AsNoTracking()
.Include(l => l.MediaSource)
.Include(l => l.Paths)
.ToListAsync();
}
public async Task<Unit> UpdateLastScan(Library library)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
new { library.LastScan, library.Id }).ToUnit();
}
public async Task<Unit> UpdateLastScan(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
}
public async Task<List<LibraryPath>> GetLocalPaths(int libraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional)
.Match(l => l.Paths, () => new List<LibraryPath>());
}
public async Task<int> CountMediaItemsByPath(int libraryPathId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPathId });
}
public async Task SetEtag(
LibraryPath libraryPath,
Option<LibraryFolder> knownFolder,
string path,
string etag)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (LibraryFolder folder in knownFolder)
{
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { folder.Id, Etag = etag });
}
if (knownFolder.IsNone)
{
var newFolder = new LibraryFolder
{
Path = path,
PathHash = PathUtils.GetPathHash(path),
Etag = etag,
LibraryPathId = libraryPath.Id
};
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: a concurrent caller created this folder between the caller's lookup and
// this insert. The etag write is the whole point of the call, so apply it to the winner's
// row rather than failing the scan.
dbContext.Entry(newFolder).State = EntityState.Detached;
LibraryFolder winner = await GetFolder(dbContext, libraryPath.Id, path);
if (winner is null)
{
throw;
}
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET Etag = @Etag WHERE Id = @Id",
new { winner.Id, Etag = etag });
}
}
}
public async Task CleanEtagsForLibraryPath(LibraryPath libraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
IOrderedEnumerable<LibraryFolder> orderedFolders = libraryPath.LibraryFolders
.Where(f => !fileSystem.Directory.Exists(f.Path))
.OrderByDescending(lp => lp.Path.Length);
foreach (LibraryFolder folder in orderedFolders)
{
await dbContext.Connection.ExecuteAsync(
"""
DELETE FROM LibraryFolder WHERE Id = @LibraryFolderId
AND NOT EXISTS (SELECT Id FROM MediaFile WHERE LibraryFolderId = @LibraryFolderId)
AND NOT EXISTS (SELECT Id FROM LibraryFolder WHERE ParentId = @LibraryFolderId)
""",
new { LibraryFolderId = folder.Id });
}
}
public async Task<Option<int>> GetParentFolderId(
LibraryPath libraryPath,
string folder,
CancellationToken cancellationToken)
{
DirectoryInfo parent = new DirectoryInfo(folder).Parent;
if (parent is null)
{
return Option<int>.None;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.Filter(lf => lf.LibraryPathId == libraryPath.Id)
.SelectOneAsync(lf => lf.Path, lf => lf.Path == parent.FullName, cancellationToken)
.MapT(lf => lf.Id);
}
public async Task<LibraryFolder> GetOrAddFolder(
LibraryPath libraryPath,
Option<int> maybeParentFolder,
string folder)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
// load from db or create new folder. Look the folder up by (LibraryPathId, Path) rather than
// reading libraryPath.LibraryFolders: that navigation collection is only eager-loaded on the
// local scan path (via GetLibrary) and is null on the remote (Jellyfin) sync path, which used
// to NRE every Jellyfin music-video scan here (ersatztv#488). The local scanners already hit
// the db once per folder via GetParentFolderId, so this adds no new query pattern.
LibraryFolder knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
// add new folder to library path
if (knownFolder is null)
{
LibraryFolder newFolder = CreateNewFolder(libraryPath, maybeParentFolder, folder);
try
{
await dbContext.LibraryFolders.AddAsync(newFolder);
await dbContext.SaveChangesAsync();
knownFolder = newFolder;
}
catch (DbUpdateException ex) when (TvContext.IsUniqueConstraintViolation(ex))
{
// ersatztv#491: the lookup above is not atomic with this insert, so a concurrent caller
// scanning the same folder can slip its row in between. The unique index on
// (LibraryPathId, PathHash) turns that lost race into a constraint violation instead of a
// duplicate row; adopt the winner's row rather than failing the scan. Detach first so the
// failed insert is not retried by anything reusing this context.
dbContext.Entry(newFolder).State = EntityState.Detached;
knownFolder = await GetFolder(dbContext, libraryPath.Id, folder);
if (knownFolder is null)
{
// no winner to adopt — the violation came from somewhere else, so don't swallow it
throw;
}
}
}
else if (string.IsNullOrEmpty(knownFolder.PathHash))
{
// Heal a row created before the PathHash column existed, so it participates in the unique
// index from here on (a null hash is distinct from every other value, so it does not).
//
// This is opportunistic maintenance on a hot scan path, so it must never be able to abort a
// scan. It goes through EF rather than a raw Dapper UPDATE precisely so a collision surfaces
// as a classifiable DbUpdateException instead of a bare provider exception, and a lost heal
// is simply left for the next scan. Reachable only if some other row already owns
// (LibraryPathId, hash) — a legacy duplicate the migration's dedupe could not see (e.g. one
// with a NULL Path, which `NULL = NULL` excludes from its grouping).
string pathHash = PathUtils.GetPathHash(folder);
LibraryFolder tracked = null;
try
{
// the predicate must agree with the IsNullOrEmpty guard above, or a PathHash = '' row would
// enter this branch, match nothing, and silently never heal
tracked = await dbContext.LibraryFolders
.FirstOrDefaultAsync(f => f.Id == knownFolder.Id && (f.PathHash == null || f.PathHash == ""));
if (tracked is not null)
{
tracked.PathHash = pathHash;
await dbContext.SaveChangesAsync();
knownFolder.PathHash = pathHash;
}
}
catch (DbUpdateException ex) when (
TvContext.IsUniqueConstraintViolation(ex) || ex is DbUpdateConcurrencyException)
{
// Either another row already owns this hash, or the row was deleted out from under us by a
// concurrent library edit (DbUpdateConcurrencyException derives from DbUpdateException but
// carries no provider exception, so the classifier does NOT recognize it). Both mean "the
// heal is moot" — leave the row unhealed rather than fail the scan, per the invariant above.
if (tracked is not null)
{
// drop the failed change so it cannot be replayed by a later save on this context
dbContext.Entry(tracked).State = EntityState.Detached;
}
}
}
// update parent folder if not present
foreach (int parentFolder in maybeParentFolder)
{
if (knownFolder.ParentId != parentFolder)
{
knownFolder.ParentId = parentFolder;
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryFolder SET ParentId = @ParentId WHERE Id = @Id",
new { ParentId = parentFolder, knownFolder.Id });
}
}
return knownFolder;
}
public async Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
mediaFile.LibraryFolderId = libraryFolderId;
await dbContext.Connection.ExecuteAsync(
"UPDATE MediaFile SET LibraryFolderId = @LibraryFolderId WHERE Id = @Id",
new { LibraryFolderId = libraryFolderId, mediaFile.Id });
}
public async Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
libraryPath.Path = normalizedLibraryPath;
await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET Path = @Path WHERE Id = @Id",
new { Path = normalizedLibraryPath, libraryPath.Id });
}
/// <summary>
/// The in-memory half of <see cref="GetFolder" />, lifted out so the ordinal decision is pinned by a
/// test with no database at all: the collation behaviour that makes it necessary is MySQL-only, so a
/// SQLite-backed test cannot exercise it (SQLite's <c>=</c> on TEXT is already binary and never
/// returns the case-differing candidate). Given the candidates a case-INsensitive server may return,
/// pick the one whose path matches ordinally; callers pass them lowest <c>Id</c> first.
/// </summary>
public static LibraryFolder ResolveExact(IReadOnlyList<LibraryFolder> candidates, string folder)
{
for (var i = 0; i < candidates.Count; i++)
{
if (string.Equals(candidates[i].Path, folder, StringComparison.Ordinal))
{
return candidates[i];
}
}
return null;
}
/// <summary>
/// Resolve a folder by its exact path within a library path.
/// <para>
/// The SQL equality is only a *narrowing* filter, not the identity test. On MySQL, `Path` is a
/// `longtext` whose collation the schema does not pin — only the `utf8mb4` charset — so the
/// effective comparison is whatever the server defaults to, and it differs from byte equality:
/// <list type="bullet">
/// <item>
/// always case-INsensitive: both plausible defaults are `_ci` (8.4 verified:
/// `utf8mb4_0900_ai_ci`; older servers `utf8mb4_general_ci`), which is why
/// <see cref="TvContext.CaseInsensitiveCollation" /> exists at all;
/// </item>
/// <item>
/// possibly PAD SPACE, making trailing spaces insignificant — true of
/// `utf8mb4_general_ci`, but NOT of `utf8mb4_0900_ai_ci`, which is NO PAD. So this axis
/// is server-dependent rather than guaranteed, and must be tolerated rather than
/// assumed either way.
/// </item>
/// </list>
/// `Path = @folder` can therefore also match siblings differing only in case, or (on a PAD
/// SPACE server) in trailing whitespace — all legal on a case-sensitive filesystem, and all
/// preserved by the #491 migration. Crucially the SQL predicate is a *superset*: every such
/// quirk makes it more permissive, never less, so it cannot miss a byte-exact match. Identity
/// is then settled in memory by <see cref="ResolveExact" /> with an ORDINAL comparison,
/// matching <c>PathUtils.GetPathHash</c>, which hashes the exact bytes. Without this the lookup
/// and the hash disagree, and the PathHash heal could stamp one sibling's hash onto the other's
/// row.
/// </para>
/// <para>
/// Ordered by Id so the result is deterministic: an unordered <c>FirstOrDefault</c> may return a
/// different candidate run to run as the query plan changes (adding the composite index alone
/// can flip it), which would make the heal non-idempotent.
/// </para>
/// </summary>
private static async Task<LibraryFolder> GetFolder(TvContext dbContext, int libraryPathId, string folder)
{
List<LibraryFolder> candidates = await dbContext.LibraryFolders
.AsNoTracking()
.Filter(f => f.LibraryPathId == libraryPathId && f.Path == folder)
.OrderBy(f => f.Id)
.ToListAsync();
return ResolveExact(candidates, folder);
}
private static LibraryFolder CreateNewFolder(LibraryPath libraryPath, Option<int> maybeParentFolder, string folder)
{
int? parentId = null;
foreach (int parentFolder in maybeParentFolder)
{
parentId = parentFolder;
}
return new LibraryFolder
{
Path = folder,
PathHash = PathUtils.GetPathHash(folder),
Etag = null,
LibraryPathId = libraryPath.Id,
ParentId = parentId
};
}
}