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 dbContextFactory) : ILibraryRepository { public async Task Add(LibraryPath libraryPath) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); await dbContext.LibraryPaths.AddAsync(libraryPath); await dbContext.SaveChangesAsync(); return libraryPath; } public async Task> 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> 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> GetLibraryIdForPath(int libraryPathId) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); int? libraryId = await dbContext.Connection.QuerySingleOrDefaultAsync( "SELECT LibraryId FROM LibraryPath WHERE Id = @LibraryPathId", new { LibraryPathId = libraryPathId }); return Optional(libraryId); } public async Task> 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 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 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> 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()); } public async Task CountMediaItemsByPath(int libraryPathId) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(); return await dbContext.Connection.QuerySingleAsync( @"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId", new { LibraryPathId = libraryPathId }); } public async Task SetEtag( LibraryPath libraryPath, Option 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 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> GetParentFolderId( LibraryPath libraryPath, string folder, CancellationToken cancellationToken) { DirectoryInfo parent = new DirectoryInfo(folder).Parent; if (parent is null) { return Option.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 GetOrAddFolder( LibraryPath libraryPath, Option 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 }); } /// /// Resolve a folder by its exact path within a library path. /// /// The SQL equality is only a *narrowing* filter, not the identity test: on MySQL, `Path` is a /// `longtext` under the case-INsensitive server default (the same collation the codebase names /// in ), so `Path = @folder` also matches /// sibling folders differing only in case — which are legal on a case-sensitive filesystem and /// which the #491 migration deliberately preserves. Identity is settled in memory with an /// ORDINAL comparison, matching PathUtils.GetPathHash, which hashes the exact bytes. /// Without this the case-insensitive lookup and the case-sensitive hash disagree, and the /// PathHash heal below could stamp one sibling's hash onto the other's row. /// /// /// Ordered by Id so the result is deterministic: an unordered FirstOrDefault 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. /// /// /// /// The in-memory half of , 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 = 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, lowest Id first. /// public static LibraryFolder ResolveExact(IReadOnlyList 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; } private static async Task GetFolder(TvContext dbContext, int libraryPathId, string folder) { List 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 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 }; } }