Files
ersatztv/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
T
timothy 890745d1d4 fix(460): write null LastScan on disable-sync instead of the MinValue sentinel [decisions-edit]
MediaSourceRepository's Plex/Jellyfin/Emby remove-and-recreate (disable-sync)
flows stamped SystemTime.MinValueUtc into library.LastScan, so normal use kept
minting 0001-01-01 sentinel rows. #409 fixed the READ side (the API coerces the
sentinel to null and a migration cleaned the historical residue), so the wire
contract was already correct — this stops new sentinel rows being written.

Safe because every remaining LastScan reader either coalesces
(LastScan ?? SystemTime.MinValueUtc) for its own non-nullable scan-comparison
needs, or is the API read-boundary coercion itself — audited every reference
under ErsatzTV{,.Application,.Core,.Infrastructure,.Scanner,.Mcp} plus web/.
There is no Where/OrderBy/GroupBy on LastScan anywhere, so the SQL
null-ordering divergence between SQLite and MySQL has no surface here.
Scan-comparison behavior is unchanged.

Deliberately ships NO second cleanup migration: rows written between #409's
NullOutNeverScannedLastScan and this change keep the sentinel at rest, and the
permanent read coercion — not a migration — is what keeps the contract honest
for them (as it must be anyway for a restored or hand-edited DB).
LibraryPath.LastScan is likewise left untouched: the flows re-add Paths with
their original values, and its only readers are the local-library scan
handlers, so it has no API surface and no remote-scan effect.

Regression test per provider (MediaSourceRepositoryDisableSyncTests), proven
non-vacuous: restoring the MinValue writes fails all three.

[decisions-edit] — the media.lastscan-null-boundary record documented this
write as an ONGOING sentinel source and rested its "the coercion is permanent"
argument on it, so the rationale prose is corrected in the same PR per
docs.decision-lifecycle. The Rule line is unchanged, so the generated
decisions/README.md catalog is byte-identical.

MediaSourceRepository.cs also loses its UTF-8 BOM (fix-as-you-touch, #311).

fixes #460
2026-07-25 13:46:09 +02:00

1021 lines
40 KiB
C#

using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory, ILogger<MediaSourceRepository> logger)
: IMediaSourceRepository
{
public async Task<PlexMediaSource> Add(PlexMediaSource plexMediaSource)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
await context.PlexMediaSources.AddAsync(plexMediaSource);
await context.SaveChangesAsync();
return plexMediaSource;
}
public async Task<List<PlexMediaSource>> GetAllPlex()
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.PlexMediaSources
.AsNoTracking()
.Include(p => p.Connections)
.ToListAsync();
}
public async Task<List<PlexPathReplacement>> GetPlexPathReplacements(int plexMediaSourceId)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.PlexPathReplacements
.Include(ppr => ppr.PlexMediaSource)
.Filter(r => r.PlexMediaSourceId == plexMediaSourceId)
.ToListAsync();
}
public async Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.PlexLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == plexLibraryId)
.Map(Optional);
}
public async Task<Option<PlexMediaSource>> GetPlex(int id, CancellationToken cancellationToken)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await context.PlexMediaSources
.AsNoTracking()
.Include(p => p.Connections)
.Include(p => p.Libraries)
.Include(p => p.PathReplacements)
.SelectOneAsync(s => s.Id, s => s.Id == id, cancellationToken);
}
public async Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
int? id = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
@"SELECT L.MediaSourceId FROM Library L
INNER JOIN PlexLibrary PL on L.Id = PL.Id
WHERE L.Id = @PlexLibraryId",
new { PlexLibraryId = plexLibraryId });
return await dbContext.PlexMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.OrderBy(p => p.Id)
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public async Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(
int plexLibraryPathId,
CancellationToken cancellationToken)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await context.PlexPathReplacements
.FromSqlRaw(
@"select ppr.* from LibraryPath lp
inner join PlexLibrary pl ON pl.Id = lp.LibraryId
inner join Library l ON l.Id = pl.Id
inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
plexLibraryPathId)
.Include(ppr => ppr.PlexMediaSource)
.ToListAsync(cancellationToken);
}
public async Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
dbContext.Entry(plexMediaSource).State = EntityState.Modified;
if (toAdd.Count != 0 || toDelete.Count != 0)
{
plexMediaSource.Connections.Clear();
await dbContext.Entry(plexMediaSource).Collection(pms => pms.Connections).LoadAsync();
plexMediaSource.Connections.AddRange(toAdd);
plexMediaSource.Connections.RemoveAll(toDelete.Contains);
}
else
{
foreach (PlexConnection connection in plexMediaSource.Connections)
{
dbContext.Entry(connection).State = EntityState.Modified;
}
}
// TODO: possibly caused by https://github.com/dotnet/efcore/issues/33133
var success = false;
var attempts = 0;
while (!success && attempts < 3)
{
try
{
await dbContext.SaveChangesAsync();
success = true;
}
catch (DbUpdateException)
{
// do nothing
attempts++;
}
}
}
public async Task<List<int>> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete,
List<PlexLibrary> toUpdate,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (PlexLibrary add in toAdd)
{
add.MediaSourceId = plexMediaSourceId;
dbContext.Entry(add).State = EntityState.Added;
foreach (LibraryPath path in add.Paths)
{
dbContext.Entry(path).State = EntityState.Added;
}
}
var libraryIds = toDelete.Map(l => l.Id).ToList();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync(cancellationToken);
foreach (PlexLibrary delete in toDelete)
{
dbContext.PlexLibraries.Remove(delete);
}
foreach (PlexLibrary incoming in toUpdate)
{
Option<PlexLibrary> maybeExisting = await dbContext.PlexLibraries
.Include(l => l.Paths)
.SingleOrDefaultAsync(
l => l.Key == incoming.Key && l.MediaSourceId == plexMediaSourceId,
cancellationToken);
foreach (PlexLibrary existingLibrary in maybeExisting)
{
// update library type, but only if not synchronized
if (incoming.MediaKind != existingLibrary.MediaKind)
{
if (existingLibrary.ShouldSyncItems)
{
logger.LogWarning(
"Plex library \"{Name}\" should be type {NewType} (currently {OldType}) but cannot be updated while synchronization is enabled for this library.",
incoming.Name,
incoming.MediaKind,
existingLibrary.MediaKind);
}
else
{
existingLibrary.MediaKind = incoming.MediaKind;
}
}
// update library path (for other video metadata)
foreach (LibraryPath existing in existingLibrary.Paths.HeadOrNone())
{
foreach (LibraryPath path in incoming.Paths.HeadOrNone())
{
existing.Path = path.Path;
}
}
existingLibrary.Name = incoming.Name;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return deletedMediaIds;
}
public async Task<List<int>> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete,
List<JellyfinLibrary> toUpdate,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (JellyfinLibrary add in toAdd)
{
add.MediaSourceId = jellyfinMediaSourceId;
dbContext.JellyfinLibraries.Add(add);
}
var libraryIds = toDelete.Map(l => l.Id).ToList();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync(cancellationToken);
foreach (JellyfinLibrary delete in toDelete)
{
dbContext.JellyfinLibraries.Remove(delete);
}
foreach (JellyfinLibrary incoming in toUpdate)
{
Option<JellyfinLibrary> maybeExisting = await dbContext.JellyfinLibraries
.Include(l => l.PathInfos)
.SingleOrDefaultAsync(
l => l.ItemId == incoming.ItemId && l.MediaSourceId == jellyfinMediaSourceId,
cancellationToken);
foreach (JellyfinLibrary existing in maybeExisting)
{
// update library type, but only if not synchronized
if (incoming.MediaKind != existing.MediaKind)
{
if (existing.ShouldSyncItems)
{
logger.LogWarning(
"Jellyfin library \"{Name}\" should be type {NewType} (currently {OldType}) but cannot be updated while synchronization is enabled for this library.",
incoming.Name,
incoming.MediaKind,
existing.MediaKind);
}
else
{
existing.MediaKind = incoming.MediaKind;
}
}
// remove paths that are not on the incoming version
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
// update all remaining paths
foreach (JellyfinPathInfo existingPathInfo in existing.PathInfos)
{
Option<JellyfinPathInfo> maybeIncoming = incoming.PathInfos
.Find(pi => pi.Path == existingPathInfo.Path);
foreach (JellyfinPathInfo incomingPathInfo in maybeIncoming)
{
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
}
}
foreach (JellyfinPathInfo incomingPathInfo in incoming.PathInfos
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
{
existing.PathInfos.Add(incomingPathInfo);
}
existing.Name = incoming.Name;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return deletedMediaIds;
}
public async Task<List<int>> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete,
List<EmbyLibrary> toUpdate,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (EmbyLibrary add in toAdd)
{
add.MediaSourceId = embyMediaSourceId;
dbContext.EmbyLibraries.Add(add);
}
var libraryIds = toDelete.Map(l => l.Id).ToList();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync(cancellationToken);
foreach (EmbyLibrary delete in toDelete)
{
dbContext.EmbyLibraries.Remove(delete);
}
foreach (EmbyLibrary incoming in toUpdate)
{
Option<EmbyLibrary> maybeExisting = await dbContext.EmbyLibraries
.Include(l => l.PathInfos)
.SingleOrDefaultAsync(
l => l.ItemId == incoming.ItemId && l.MediaSourceId == embyMediaSourceId,
cancellationToken);
foreach (EmbyLibrary existing in maybeExisting)
{
// update library type, but only if not synchronized
if (incoming.MediaKind != existing.MediaKind)
{
if (existing.ShouldSyncItems)
{
logger.LogWarning(
"Emby library \"{Name}\" should be type {NewType} (currently {OldType}) but cannot be updated while synchronization is enabled for this library.",
incoming.Name,
incoming.MediaKind,
existing.MediaKind);
}
else
{
existing.MediaKind = incoming.MediaKind;
}
}
// remove paths that are not on the incoming version
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
// update all remaining paths
foreach (EmbyPathInfo existingPathInfo in existing.PathInfos)
{
Option<EmbyPathInfo> maybeIncoming = incoming.PathInfos
.Find(pi => pi.Path == existingPathInfo.Path);
foreach (EmbyPathInfo incomingPathInfo in maybeIncoming)
{
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
}
}
foreach (EmbyPathInfo incomingPathInfo in incoming.PathInfos
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
{
existing.PathInfos.Add(incomingPathInfo);
}
existing.Name = incoming.Name;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return deletedMediaIds;
}
public async Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
List<PlexPathReplacement> toUpdate,
List<PlexPathReplacement> toDelete)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (PlexPathReplacement add in toAdd)
{
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO PlexPathReplacement
(PlexPath, LocalPath, PlexMediaSourceId)
VALUES (@PlexPath, @LocalPath, @PlexMediaSourceId)",
new { add.PlexPath, add.LocalPath, PlexMediaSourceId = plexMediaSourceId });
}
foreach (PlexPathReplacement update in toUpdate)
{
// Scope the UPDATE to the owning source (#202 finding 2c): without the PlexMediaSourceId
// predicate a PUT to source A could overwrite source B's row by id (defense-in-depth behind
// the handler's ownership guard).
await dbContext.Connection.ExecuteAsync(
@"UPDATE PlexPathReplacement
SET PlexPath = @PlexPath, LocalPath = @LocalPath
WHERE Id = @Id AND PlexMediaSourceId = @PlexMediaSourceId",
new { update.PlexPath, update.LocalPath, update.Id, PlexMediaSourceId = plexMediaSourceId });
}
foreach (PlexPathReplacement delete in toDelete)
{
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM PlexPathReplacement WHERE Id = @Id",
new { delete.Id });
}
return Unit.Default;
}
public async Task<List<int>> DeleteAllPlex()
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<PlexMediaSource> allMediaSources = await dbContext.PlexMediaSources.ToListAsync();
dbContext.PlexMediaSources.RemoveRange(allMediaSources);
List<PlexLibrary> allPlexLibraries = await dbContext.PlexLibraries.ToListAsync();
dbContext.PlexLibraries.RemoveRange(allPlexLibraries);
var libraryIds = allPlexLibraries.Map(l => l.Id).ToList();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<int> mediaItemIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE L.MediaSourceId = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaSource WHERE Id = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id });
return mediaItemIds;
}
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<PlexLibrary> libraries = await dbContext.PlexLibraries
.Include(l => l.Paths)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
dbContext.PlexLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
foreach (PlexLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
// ersatztv#460: "never scanned" is null, not the 0001-01-01 MinValue sentinel. Every read path
// either coalesces (`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable
// scan-comparison, or is the API read-boundary coercion (`NormalizeLastScan`, which maps the
// sentinel to null anyway) — so scan-comparison behavior is unchanged.
library.LastScan = null;
}
await dbContext.PlexLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task EnablePlexLibrarySync(IEnumerable<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.Connection.ExecuteAsync(
"UPDATE PlexLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds });
}
public async Task<Unit> UpsertJellyfin(string address, string serverName, string operatingSystem)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Option<JellyfinMediaSource> maybeExisting = dbContext.JellyfinMediaSources
.Include(ms => ms.Connections)
.OrderBy(ms => ms.Id)
.HeadOrNone();
return await maybeExisting.Match(
async jellyfinMediaSource =>
{
if (jellyfinMediaSource.Connections.Count == 0)
{
jellyfinMediaSource.Connections.Add(new JellyfinConnection { Address = address });
}
else if (jellyfinMediaSource.Connections.Head().Address != address)
{
jellyfinMediaSource.Connections.Head().Address = address;
}
if (jellyfinMediaSource.ServerName != serverName)
{
jellyfinMediaSource.ServerName = serverName;
}
if (jellyfinMediaSource.OperatingSystem != operatingSystem)
{
jellyfinMediaSource.OperatingSystem = operatingSystem;
}
await dbContext.SaveChangesAsync();
return Unit.Default;
},
async () =>
{
var mediaSource = new JellyfinMediaSource
{
ServerName = serverName,
OperatingSystem = operatingSystem,
Connections = new List<JellyfinConnection>
{
new() { Address = address }
},
PathReplacements = new List<JellyfinPathReplacement>()
};
await dbContext.AddAsync(mediaSource);
await dbContext.SaveChangesAsync();
return Unit.Default;
});
}
public async Task<List<JellyfinMediaSource>> GetAllJellyfin(CancellationToken cancellationToken)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await context.JellyfinMediaSources
.AsNoTracking()
.Include(p => p.Connections)
.ToListAsync(cancellationToken);
}
public async Task<Option<JellyfinMediaSource>> GetJellyfin(int id)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.JellyfinMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.ThenInclude(l => (l as JellyfinLibrary).PathInfos)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public async Task<List<JellyfinLibrary>> GetJellyfinLibraries(int jellyfinMediaSourceId)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.JellyfinLibraries
.Filter(l => l.MediaSourceId == jellyfinMediaSourceId)
.ToListAsync();
}
public async Task<Unit> EnableJellyfinLibrarySync(IEnumerable<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds }).ToUnit();
}
public async Task<List<int>> DisableJellyfinLibrarySync(List<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<JellyfinLibrary> libraries = await dbContext.JellyfinLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
dbContext.JellyfinLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
foreach (JellyfinLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
// ersatztv#460: "never scanned" is null, not the 0001-01-01 MinValue sentinel. Every read path
// either coalesces (`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable
// scan-comparison, or is the API read-boundary coercion (`NormalizeLastScan`, which maps the
// sentinel to null anyway) — so scan-comparison behavior is unchanged.
library.LastScan = null;
}
await dbContext.JellyfinLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync();
return await context.JellyfinLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Include(l => l.MediaSource)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
.Map(Optional);
}
public async Task<Option<JellyfinMediaSource>> GetJellyfinByLibraryId(int jellyfinLibraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
int? id = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
@"SELECT L.MediaSourceId FROM Library L
INNER JOIN JellyfinLibrary PL on L.Id = PL.Id
WHERE L.Id = @JellyfinLibraryId",
new { JellyfinLibraryId = jellyfinLibraryId });
return await dbContext.JellyfinMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.OrderBy(p => p.Id)
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public async Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacements(int jellyfinMediaSourceId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.JellyfinPathReplacements
.Filter(r => r.JellyfinMediaSourceId == jellyfinMediaSourceId)
.Include(jpr => jpr.JellyfinMediaSource)
.ToListAsync();
}
public async Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacementsByLibraryId(
int jellyfinLibraryPathId,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.JellyfinPathReplacements
.FromSqlRaw(
@"select jpr.* from LibraryPath lp
inner join JellyfinLibrary jl ON jl.Id = lp.LibraryId
inner join Library l ON l.Id = jl.Id
inner join JellyfinPathReplacement jpr on jpr.JellyfinMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
jellyfinLibraryPathId)
.Include(jpr => jpr.JellyfinMediaSource)
.ToListAsync(cancellationToken);
}
public async Task<Unit> UpdatePathReplacements(
int jellyfinMediaSourceId,
List<JellyfinPathReplacement> toAdd,
List<JellyfinPathReplacement> toUpdate,
List<JellyfinPathReplacement> toDelete)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (JellyfinPathReplacement add in toAdd)
{
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO JellyfinPathReplacement
(JellyfinPath, LocalPath, JellyfinMediaSourceId)
VALUES (@JellyfinPath, @LocalPath, @JellyfinMediaSourceId)",
new { add.JellyfinPath, add.LocalPath, JellyfinMediaSourceId = jellyfinMediaSourceId });
}
foreach (JellyfinPathReplacement update in toUpdate)
{
await dbContext.Connection.ExecuteAsync(
@"UPDATE JellyfinPathReplacement
SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath
WHERE Id = @Id AND JellyfinMediaSourceId = @JellyfinMediaSourceId",
new
{
update.JellyfinPath,
update.LocalPath,
update.Id,
JellyfinMediaSourceId = jellyfinMediaSourceId
});
}
foreach (JellyfinPathReplacement delete in toDelete)
{
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM JellyfinPathReplacement WHERE Id = @Id",
new { delete.Id });
}
return Unit.Default;
}
public async Task<List<int>> DeleteAllJellyfin()
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<JellyfinMediaSource> allMediaSources = await dbContext.JellyfinMediaSources.ToListAsync();
var mediaSourceIds = allMediaSources.Map(ms => ms.Id).ToList();
dbContext.JellyfinMediaSources.RemoveRange(allMediaSources);
List<JellyfinLibrary> allJellyfinLibraries = await dbContext.JellyfinLibraries
.Where(l => mediaSourceIds.Contains(l.MediaSourceId))
.ToListAsync();
var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList();
dbContext.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
Option<EmbyMediaSource> maybeExisting = dbContext.EmbyMediaSources
.Include(ms => ms.Connections)
.OrderBy(ms => ms.Id)
.HeadOrNone();
return await maybeExisting.Match(
async embyMediaSource =>
{
if (embyMediaSource.Connections.Count == 0)
{
embyMediaSource.Connections.Add(new EmbyConnection { Address = address });
}
else if (embyMediaSource.Connections.Head().Address != address)
{
embyMediaSource.Connections.Head().Address = address;
}
if (embyMediaSource.ServerName != serverName)
{
embyMediaSource.ServerName = serverName;
}
if (embyMediaSource.OperatingSystem != operatingSystem)
{
embyMediaSource.OperatingSystem = operatingSystem;
}
await dbContext.SaveChangesAsync();
return Unit.Default;
},
async () =>
{
var mediaSource = new EmbyMediaSource
{
ServerName = serverName,
OperatingSystem = operatingSystem,
Connections = new List<EmbyConnection>
{
new() { Address = address }
},
PathReplacements = new List<EmbyPathReplacement>()
};
await dbContext.AddAsync(mediaSource);
await dbContext.SaveChangesAsync();
return Unit.Default;
});
}
public async Task<List<EmbyMediaSource>> GetAllEmby(CancellationToken cancellationToken)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await context.EmbyMediaSources
.AsNoTracking()
.Include(p => p.Connections)
.ToListAsync(cancellationToken);
}
public async Task<Option<EmbyMediaSource>> GetEmby(int id, CancellationToken cancellationToken)
{
await using TvContext context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await context.EmbyMediaSources
.AsNoTracking()
.Include(p => p.Connections)
.Include(p => p.Libraries)
.ThenInclude(l => (l as EmbyLibrary).PathInfos)
.Include(p => p.PathReplacements)
.SelectOneAsync(s => s.Id, s => s.Id == id, cancellationToken);
}
public async Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
int? id = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
@"SELECT L.MediaSourceId FROM Library L
INNER JOIN EmbyLibrary PL on L.Id = PL.Id
WHERE L.Id = @EmbyLibraryId",
new { EmbyLibraryId = embyLibraryId });
return await dbContext.EmbyMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.OrderBy(p => p.Id)
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public async Task<Option<EmbyLibrary>> GetEmbyLibrary(int embyLibraryId, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.EmbyLibraries
.AsNoTracking()
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Include(l => l.MediaSource)
.SelectOneAsync(l => l.Id, l => l.Id == embyLibraryId, cancellationToken);
}
public async Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.EmbyLibraries
.Filter(l => l.MediaSourceId == embyMediaSourceId)
.ToListAsync();
}
public async Task<List<EmbyPathReplacement>> GetEmbyPathReplacements(int embyMediaSourceId)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.EmbyPathReplacements
.Filter(r => r.EmbyMediaSourceId == embyMediaSourceId)
.Include(jpr => jpr.EmbyMediaSource)
.ToListAsync();
}
public async Task<List<EmbyPathReplacement>> GetEmbyPathReplacementsByLibraryId(
int embyLibraryPathId,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.EmbyPathReplacements
.FromSqlRaw(
@"select epr.* from LibraryPath lp
inner join EmbyLibrary el ON el.Id = lp.LibraryId
inner join Library l ON l.Id = el.Id
inner join EmbyPathReplacement epr on epr.EmbyMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
embyLibraryPathId)
.Include(jpr => jpr.EmbyMediaSource)
.ToListAsync(cancellationToken);
}
public async Task<Unit> UpdatePathReplacements(
int embyMediaSourceId,
List<EmbyPathReplacement> toAdd,
List<EmbyPathReplacement> toUpdate,
List<EmbyPathReplacement> toDelete)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (EmbyPathReplacement add in toAdd)
{
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO EmbyPathReplacement
(EmbyPath, LocalPath, EmbyMediaSourceId)
VALUES (@EmbyPath, @LocalPath, @EmbyMediaSourceId)",
new { add.EmbyPath, add.LocalPath, EmbyMediaSourceId = embyMediaSourceId });
}
foreach (EmbyPathReplacement update in toUpdate)
{
await dbContext.Connection.ExecuteAsync(
@"UPDATE EmbyPathReplacement
SET EmbyPath = @EmbyPath, LocalPath = @LocalPath
WHERE Id = @Id AND EmbyMediaSourceId = @EmbyMediaSourceId",
new
{
update.EmbyPath,
update.LocalPath,
update.Id,
EmbyMediaSourceId = embyMediaSourceId
});
}
foreach (EmbyPathReplacement delete in toDelete)
{
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyPathReplacement WHERE Id = @Id",
new { delete.Id });
}
return Unit.Default;
}
public async Task<List<int>> DeleteAllEmby()
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<EmbyMediaSource> allMediaSources = await dbContext.EmbyMediaSources.ToListAsync();
var mediaSourceIds = allMediaSources.Map(ms => ms.Id).ToList();
dbContext.EmbyMediaSources.RemoveRange(allMediaSources);
List<EmbyLibrary> allEmbyLibraries = await dbContext.EmbyLibraries
.Where(l => mediaSourceIds.Contains(l.MediaSourceId))
.ToListAsync();
var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList();
dbContext.EmbyLibraries.RemoveRange(allEmbyLibraries);
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds }).Map(_ => Unit.Default);
}
public async Task<List<int>> DisableEmbyLibrarySync(List<int> libraryIds)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
List<int> deletedMediaIds = await dbContext.MediaItems
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
.Map(mi => mi.Id)
.ToListAsync();
List<EmbyLibrary> libraries = await dbContext.EmbyLibraries
.Include(l => l.Paths)
.Include(l => l.PathInfos)
.Filter(l => libraryIds.Contains(l.Id))
.ToListAsync();
dbContext.EmbyLibraries.RemoveRange(libraries);
await dbContext.SaveChangesAsync();
foreach (EmbyLibrary library in libraries)
{
library.Id = 0;
library.ShouldSyncItems = false;
// ersatztv#460: "never scanned" is null, not the 0001-01-01 MinValue sentinel. Every read path
// either coalesces (`LastScan ?? SystemTime.MinValueUtc`) for its own non-nullable
// scan-comparison, or is the API read-boundary coercion (`NormalizeLastScan`, which maps the
// sentinel to null anyway) — so scan-comparison behavior is unchanged.
library.LastScan = null;
}
await dbContext.EmbyLibraries.AddRangeAsync(libraries);
await dbContext.SaveChangesAsync();
return deletedMediaIds;
}
public async Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { embyMediaSource.LastCollectionsScan, embyMediaSource.Id }).ToUnit();
}
public async Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { jellyfinMediaSource.LastCollectionsScan, jellyfinMediaSource.Id }).ToUnit();
}
public async Task<Unit> UpdateLastCollectionScan(PlexMediaSource plexMediaSource)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { plexMediaSource.LastCollectionsScan, plexMediaSource.Id }).ToUnit();
}
}