Files
ersatztv/ErsatzTV.Infrastructure/Data/TvContext.cs
T
timothy 64decd492e fix(496): give music videos a per-library server identity; itemId diff + soft trash
Music videos carried no server identity, so JellyfinMusicVideoLibraryScanner had to
reconcile by a (LibraryPathId, path) diff and HARD-delete the remainder. A file served
by two libraries with overlapping local paths is a single row owned by whichever library
scanned it first, so that owner's sweep destroyed a row another library still served —
taking collection membership and playout references with it, irreversibly.

This is #494's deferred "option 2":

- New JellyfinMusicVideo : MusicVideo (ItemId/Etag), mirroring JellyfinMovie — TPT table,
  varchar(36), ItemId index. Dual-provider migration Add_JellyfinMusicVideo.
- New IMediaServerMusicVideoRepository + JellyfinMusicVideoRepository: itemId-keyed
  existing-set/lookup and Flag{Normal,Unavailable,FileNotFound} seams, all scoped per
  library via LibraryPath.LibraryId.
- New MediaServerMusicVideoLibraryScanner base; JellyfinMusicVideoLibraryScanner folds
  onto it and keeps the #177/#488/#497/#500 metadata-reconcile logic verbatim.
- The sweep now soft-trashes (FileNotFound) instead of deleting, so removal is reversible
  and EmptyTrash-governed. DeleteEmptyArtists consequently no longer fires from a sweep.
- Pre-identity rows are ADOPTED in place: the identity row is inserted against the same
  MediaItem id, scoped to the scanned library's own LibraryPath, so collection membership
  survives and a local/second-library row is never hijacked.
- AddMusicVideo normalizes Path/PathHash to the path-REPLACED local path; the projection
  fills them from the server-reported path, which would break every later PathHash lookup.

Docs: scan.musicvideo-reconciliation relocated to docs/decisions/archive/scan.md as
superseded; new active record scan.musicvideo-server-identity.

fixes #496
2026-07-25 17:20:53 +02:00

199 lines
11 KiB
C#

using System.Data;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Data;
public class TvContext : DbContext
{
private readonly ILoggerFactory _loggerFactory;
private readonly SlowQueryInterceptor _slowQueryInterceptor;
public TvContext(
DbContextOptions<TvContext> options,
ILoggerFactory loggerFactory,
SlowQueryInterceptor slowQueryInterceptor)
: base(options)
{
_loggerFactory = loggerFactory;
_slowQueryInterceptor = slowQueryInterceptor;
}
public static string LastInsertedRowId { get; set; } = "last_insert_rowid()";
public static string CaseInsensitiveCollation { get; set; } = "NOCASE";
public static bool IsSqlite { get; set; }
/// <summary>
/// Recognizes a provider-specific unique / primary-key constraint violation surfaced as a
/// <see cref="DbUpdateException" /> (SQLite error 19 / MySQL 1062). Set at startup by the active
/// provider's wiring, mirroring <see cref="IsSqlite" /> / <see cref="LastInsertedRowId" />. Used to
/// turn a concurrent duplicate insert (a row we membership-checked absent, inserted first by a racing
/// request) into an idempotent no-op instead of a 500 — see <c>ConcurrencyExtensions</c> and #308.
/// Defaults to a conservative "no" so an unwired provider never silently swallows a save failure.
/// </summary>
public static Func<DbUpdateException, bool> IsUniqueConstraintViolation { get; set; } = static _ => false;
public IDbConnection Connection => Database.GetDbConnection();
public DbSet<ConfigElement> ConfigElements { get; set; }
public DbSet<Channel> Channels { get; set; }
public DbSet<ChannelTemplate> ChannelTemplates { get; set; }
public DbSet<ChannelWatermark> ChannelWatermarks { get; set; }
public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
public DbSet<PlexMediaSource> PlexMediaSources { get; set; }
public DbSet<JellyfinMediaSource> JellyfinMediaSources { get; set; }
public DbSet<EmbyMediaSource> EmbyMediaSources { get; set; }
public DbSet<Library> Libraries { get; set; }
public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<LibraryPath> LibraryPaths { get; set; }
public DbSet<LibraryFolder> LibraryFolders { get; set; }
public DbSet<PlexLibrary> PlexLibraries { get; set; }
public DbSet<JellyfinLibrary> JellyfinLibraries { get; set; }
public DbSet<EmbyLibrary> EmbyLibraries { get; set; }
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
public DbSet<JellyfinPathReplacement> JellyfinPathReplacements { get; set; }
public DbSet<EmbyPathReplacement> EmbyPathReplacements { get; set; }
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<MediaStream> MediaStreams { get; set; }
public DbSet<MediaChapter> MediaChapters { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artwork> Artwork { get; set; }
public DbSet<Artist> Artists { get; set; }
public DbSet<Actor> Actors { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<OtherVideo> OtherVideos { get; set; }
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public DbSet<Song> Songs { get; set; }
public DbSet<SongMetadata> SongMetadata { get; set; }
public DbSet<Image> Images { get; set; }
public DbSet<ImageMetadata> ImageMetadata { get; set; }
public DbSet<ImageFolderDuration> ImageFolderDurations { get; set; }
public DbSet<RemoteStream> RemoteStreams { get; set; }
public DbSet<RemoteStreamMetadata> RemoteStreamMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }
public DbSet<SeasonMetadata> SeasonMetadata { get; set; }
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<PlexOtherVideo> PlexOtherVideos { get; set; }
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<PlexCollection> PlexCollections { get; set; }
public DbSet<JellyfinMovie> JellyfinMovies { get; set; }
public DbSet<JellyfinMusicVideo> JellyfinMusicVideos { get; set; }
public DbSet<JellyfinShow> JellyfinShows { get; set; }
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }
public DbSet<JellyfinEpisode> JellyfinEpisodes { get; set; }
public DbSet<JellyfinCollection> JellyfinCollections { get; set; }
public DbSet<EmbyMovie> EmbyMovies { get; set; }
public DbSet<EmbyShow> EmbyShows { get; set; }
public DbSet<EmbySeason> EmbySeasons { get; set; }
public DbSet<EmbyEpisode> EmbyEpisodes { get; set; }
public DbSet<EmbyCollection> EmbyCollections { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<MultiCollection> MultiCollections { get; set; }
public DbSet<SmartCollection> SmartCollections { get; set; }
public DbSet<RerunCollection> RerunCollections { get; set; }
public DbSet<RerunHistory> RerunHistory { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
public DbSet<Playout> Playouts { get; set; }
public DbSet<PlayoutHistory> PlayoutHistory { get; set; }
public DbSet<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
public DbSet<PlayoutItem> PlayoutItems { get; set; }
public DbSet<PlayoutGap> PlayoutGaps { get; set; }
public DbSet<PlayoutProgramScheduleAnchor> PlayoutProgramScheduleItemAnchors { get; set; }
public DbSet<PlayoutTemplate> PlayoutTemplates { get; set; }
public DbSet<PlayoutBuildStatus> PlayoutBuildStatus { get; set; }
public DbSet<BlockGroup> BlockGroups { get; set; }
public DbSet<Block> Blocks { get; set; }
public DbSet<BlockItem> BlockItems { get; set; }
public DbSet<TemplateGroup> TemplateGroups { get; set; }
public DbSet<Template> Templates { get; set; }
public DbSet<TemplateItem> TemplateItems { get; set; }
public DbSet<DecoGroup> DecoGroups { get; set; }
public DbSet<Deco> Decos { get; set; }
public DbSet<DecoTemplateGroup> DecoTemplateGroups { get; set; }
public DbSet<DecoTemplate> DecoTemplates { get; set; }
public DbSet<DecoTemplateItem> DecoTemplateItems { get; set; }
public DbSet<PlaylistGroup> PlaylistGroups { get; set; }
public DbSet<Playlist> Playlists { get; set; }
public DbSet<PlaylistItem> PlaylistItems { get; set; }
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; }
public DbSet<LanguageCode> LanguageCodes { get; set; }
public DbSet<TraktList> TraktLists { get; set; }
public DbSet<FillerPreset> FillerPresets { get; set; }
public DbSet<Subtitle> Subtitles { get; set; }
public DbSet<GraphicsElement> GraphicsElements { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseLoggerFactory(_loggerFactory);
optionsBuilder.AddInterceptors(_slowQueryInterceptor);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
string collation = null;
// mysql-specific configuration
if ((Database.ProviderName ?? string.Empty).Contains("MySql", StringComparison.InvariantCultureIgnoreCase))
{
modelBuilder.Entity<MediaFile>().Property(mf => mf.Path).HasColumnType("longtext");
collation = "utf8mb4_general_ci";
}
// sqlite-specific configuration
if ((Database.ProviderName ?? string.Empty).Contains("Sqlite", StringComparison.InvariantCultureIgnoreCase))
{
collation = "NOCASE";
}
// case-insensitive columns
if (!string.IsNullOrEmpty(collation))
{
modelBuilder.Entity<ArtistMetadata>().Property(b => b.Title).UseCollation(collation);
modelBuilder.Entity<Block>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<BlockGroup>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<Channel>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelTemplate>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelWatermark>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Collection>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Deco>().Property(d => d.Name).UseCollation(collation);
modelBuilder.Entity<DecoGroup>().Property(d => d.Name).UseCollation(collation);
modelBuilder.Entity<DecoTemplate>().Property(d => d.Name).UseCollation(collation);
modelBuilder.Entity<DecoTemplateGroup>().Property(d => d.Name).UseCollation(collation);
modelBuilder.Entity<FillerPreset>().Property(fp => fp.Name).UseCollation(collation);
modelBuilder.Entity<MetadataGuid>().Property(mg => mg.Guid).UseCollation(collation);
modelBuilder.Entity<MovieMetadata>().Property(mm => mm.Title).UseCollation(collation);
modelBuilder.Entity<MultiCollection>().Property(mc => mc.Name).UseCollation(collation);
modelBuilder.Entity<Playlist>().Property(p => p.Name).UseCollation(collation);
modelBuilder.Entity<ProgramSchedule>().Property(ps => ps.Name).UseCollation(collation);
modelBuilder.Entity<RerunCollection>().Property(rc => rc.Name).UseCollation(collation);
modelBuilder.Entity<ShowMetadata>().Property(sm => sm.Title).UseCollation(collation);
modelBuilder.Entity<SmartCollection>().Property(sc => sc.Name).UseCollation(collation);
modelBuilder.Entity<Template>().Property(t => t.Name).UseCollation(collation);
modelBuilder.Entity<TemplateGroup>().Property(t => t.Name).UseCollation(collation);
modelBuilder.Entity<TraktList>().Property(t => t.Name).UseCollation(collation);
}
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TvContext).Assembly);
}
}