Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m47s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GetOrAddFolder read libraryPath.LibraryFolders, a navigation collection only
eager-loaded on the local scan path (via GetLibrary). The Jellyfin remote sync
path takes its LibraryPath off the JellyfinLibrary entity, where LibraryFolders
is null, so .Filter(null) threw ArgumentNullException('source') on the first
item of every Jellyfin music-video scan — a feature that had therefore never
run in prod, CI, or locally.
Look the folder up from the DB by (LibraryPathId, Path) instead, removing the
implicit eager-load contract entirely (correct for all nine callers) and
documenting it on ILibraryRepository. null != empty is preserved so a re-scan
does not insert duplicate LibraryFolder rows. No new hot-path cost: local
scanners already query GetParentFolderId per folder just before this call.
Tests:
- LibraryRepositoryTests: GetOrAddFolder with a null LibraryFolders (the exact
remote-path shape) creates the folder, is idempotent on re-scan, and persists
a supplied ParentId.
- JellyfinMusicVideoLibraryScannerTests: an end-to-end scan of one synthetic
music video, wiring the REAL LibraryRepository/ArtistRepository/
MusicVideoRepository against in-memory SQLite (the existing MediaServer*
scanner tests mock every repo, which is why the bug escaped), asserts the scan
completes and creates Artist + MusicVideo rows with a real LibraryFolder.
Both proven non-vacuous against the reverted fix (each reproduces the issue's
ArgumentNullException). decisions.md entry added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
9.0 KiB
C#
241 lines
9.0 KiB
C#
using System.IO.Abstractions;
|
|
using Dapper;
|
|
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)
|
|
{
|
|
await dbContext.LibraryFolders.AddAsync(
|
|
new LibraryFolder
|
|
{
|
|
Path = path,
|
|
Etag = etag,
|
|
LibraryPathId = libraryPath.Id
|
|
});
|
|
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
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 dbContext.LibraryFolders
|
|
.AsNoTracking()
|
|
.Filter(f => f.LibraryPathId == libraryPath.Id && f.Path == folder)
|
|
.FirstOrDefaultAsync()
|
|
?? CreateNewFolder(libraryPath, maybeParentFolder, folder);
|
|
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
// add new folder to library path
|
|
if (knownFolder.Id < 1)
|
|
{
|
|
await dbContext.LibraryFolders.AddAsync(knownFolder);
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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,
|
|
Etag = null,
|
|
LibraryPathId = libraryPath.Id,
|
|
ParentId = parentId
|
|
};
|
|
}
|
|
}
|