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
384 lines
17 KiB
C#
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
|
|
};
|
|
}
|
|
}
|