using System.Globalization; using System.Reflection; using Dapper; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Scheduling; using ErsatzTV.FFmpeg.State; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Infrastructure.Data; public static class DbInitializer { public static async Task Initialize(TvContext context, CancellationToken cancellationToken) { if (TvContext.IsSqlite) { await context.Connection.ExecuteAsync("PRAGMA journal_mode=WAL", cancellationToken); } else { int localInfile = await context.Connection.ExecuteScalarAsync( "SELECT @@GLOBAL.local_infile"); if (localInfile == 0) { try { await context.Connection.ExecuteAsync("SET GLOBAL local_infile = 'ON'", cancellationToken); } catch { Serilog.Log.Fatal( """ ErsatzTV requires local_infile=ON when using MySQL. Please run the following as a MySQL administrator: SET GLOBAL local_infile = 'ON'; Or configure it in my.cnf under [mysqld]. """); throw; } } } if (!context.LanguageCodes.Any()) { var assembly = Assembly.GetEntryAssembly(); if (assembly != null) { await using Stream resource = assembly.GetManifestResourceStream("ErsatzTV.Resources.ISO-639-2_utf-8.txt"); if (resource != null) { using var reader = new StreamReader(resource); string line; while ((line = await reader.ReadLineAsync(cancellationToken)) is not null) { string[] split = line.Split("|"); if (split.Length == 5) { var languageCode = new LanguageCode { ThreeCode1 = split[0], ThreeCode2 = split[1], TwoCode = split[2], EnglishName = split[3], FrenchName = split[4] }; await context.LanguageCodes.AddAsync(languageCode, cancellationToken); } } } } await context.SaveChangesAsync(cancellationToken); } if (!context.PlaylistGroups.Any(pg => pg.IsSystem)) { var pg = new PlaylistGroup { Name = "Trakt Lists", IsSystem = true }; await context.PlaylistGroups.AddAsync(pg, cancellationToken); await context.SaveChangesAsync(cancellationToken); } if (!context.Resolutions.Any(x => x.Width == 1920)) { var resolutions = new List { new() { Id = 1, Name = "720x480", Width = 720, Height = 480 }, new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 }, new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 }, new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 } }; await context.Resolutions.AddRangeAsync(resolutions, cancellationToken); await context.SaveChangesAsync(cancellationToken); var resolutionConfig = new ConfigElement { Key = ConfigElementKey.FFmpegDefaultResolutionId.Key, Value = "3" // 1920x1080 }; await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken); await context.SaveChangesAsync(cancellationToken); var defaultProfile = FFmpegProfile.New("1920x1080 x264 aac", resolutions[2]); await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken); await context.SaveChangesAsync(cancellationToken); var profileConfig = new ConfigElement { Key = ConfigElementKey.FFmpegDefaultProfileId.Key, Value = defaultProfile.Id.ToString(CultureInfo.InvariantCulture) }; await context.ConfigElements.AddAsync(profileConfig, cancellationToken); await context.SaveChangesAsync(cancellationToken); var defaultChannel = new Channel(Guid.NewGuid()) { Number = "1", Name = "ErsatzTV", FFmpegProfile = defaultProfile, StreamingMode = StreamingMode.TransportStreamHybrid, IsEnabled = true, ShowInEpg = true }; await context.Channels.AddAsync(defaultChannel, cancellationToken); await context.SaveChangesAsync(cancellationToken); } int? channelBugWatermarkId = await SeedChannelBugWatermark(context, cancellationToken); await SeedChannelTemplates(context, cancellationToken, channelBugWatermarkId); // TODO: create looping static image that mentions configuring via web return Unit.Default; } private static async Task SeedChannelTemplates( TvContext context, CancellationToken cancellationToken, int? channelBugWatermarkId) { if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) && await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken)) { await EnsureDefaultChannelTemplateConfig(context, cancellationToken); return; } FFmpegProfile defaultProfile = await GetDefaultFFmpegProfile(context, cancellationToken); if (defaultProfile is null) { return; } if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken)) { await context.ChannelTemplates.AddAsync( NewSystemTemplate( "Standard", "Balanced defaults for generated channels.", defaultProfile.Id, ChannelMusicVideoCreditsMode.None, ChannelSongVideoMode.Default, shuffleScheduleItems: false, randomStartPoint: false, channelBugWatermarkId), cancellationToken); await context.SaveChangesAsync(cancellationToken); } if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken)) { await context.ChannelTemplates.AddAsync( NewSystemTemplate( "Music videos", "Defaults for shuffled music-video channels with generated credit subtitles.", defaultProfile.Id, ChannelMusicVideoCreditsMode.GenerateSubtitles, ChannelSongVideoMode.WithProgress, shuffleScheduleItems: true, randomStartPoint: true, channelBugWatermarkId), cancellationToken); await context.SaveChangesAsync(cancellationToken); } await EnsureDefaultChannelTemplateConfig(context, cancellationToken); } // A single shared preset is all that's needed: ImageSource.ChannelLogo resolves each channel's // own logo artwork at render time (WatermarkSelector), so one row makes every channel use its // own logo as its on-screen bug. // // Guarded by a ConfigElement marker rather than by name alone: ChannelWatermark has no IsSystem // flag, and Initialize runs on every startup, so a name-only guard would resurrect the row // forever after a deliberate delete. Adopting an existing same-name row (an operator's tuned // one) also sets the marker — adopt, never overwrite. private static async Task SeedChannelBugWatermark( TvContext context, CancellationToken cancellationToken) { string seededKey = ConfigElementKey.WatermarkChannelBugSeeded.Key; bool alreadySeeded = await context.ConfigElements .AnyAsync(c => c.Key == seededKey, cancellationToken); ChannelWatermark existing = await context.ChannelWatermarks .FirstOrDefaultAsync(w => w.Name == "Channel Bug", cancellationToken); if (alreadySeeded) { return existing?.Id; } if (existing is null) { existing = new ChannelWatermark { Name = "Channel Bug", Mode = ChannelWatermarkMode.Permanent, ImageSource = ChannelWatermarkImageSource.ChannelLogo, Image = null, Location = WatermarkLocation.TopLeft, Size = WatermarkSize.Scaled, WidthPercent = 5.0, HorizontalMarginPercent = 1.0, VerticalMarginPercent = 1.0, FrequencyMinutes = 0, DurationSeconds = 0, Opacity = 80, PlaceWithinSourceContent = false, ZIndex = 0 }; await context.ChannelWatermarks.AddAsync(existing, cancellationToken); } await context.ConfigElements.AddAsync( new ConfigElement { Key = seededKey, Value = "true" }, cancellationToken); await context.SaveChangesAsync(cancellationToken); return existing.Id; } private static async Task GetDefaultFFmpegProfile( TvContext context, CancellationToken cancellationToken) { string defaultProfileConfigKey = ConfigElementKey.FFmpegDefaultProfileId.Key; ConfigElement profileConfig = await context.ConfigElements .AsNoTracking() .FirstOrDefaultAsync(c => c.Key == defaultProfileConfigKey, cancellationToken); if (profileConfig is not null && int.TryParse(profileConfig.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int profileId)) { FFmpegProfile configuredProfile = await context.FFmpegProfiles .FirstOrDefaultAsync(p => p.Id == profileId, cancellationToken); if (configuredProfile is not null) { return configuredProfile; } } return await context.FFmpegProfiles.OrderBy(p => p.Id).FirstOrDefaultAsync(cancellationToken); } private static async Task EnsureDefaultChannelTemplateConfig(TvContext context, CancellationToken cancellationToken) { string defaultTemplateConfigKey = ConfigElementKey.ChannelTemplatesDefaultTemplateId.Key; bool configExists = await context.ConfigElements .AnyAsync(c => c.Key == defaultTemplateConfigKey, cancellationToken); if (configExists) { return; } ChannelTemplate standard = await context.ChannelTemplates .AsNoTracking() .FirstAsync(t => t.Name == "Standard", cancellationToken); await context.ConfigElements.AddAsync( new ConfigElement { Key = defaultTemplateConfigKey, Value = standard.Id.ToString(CultureInfo.InvariantCulture) }, cancellationToken); await context.SaveChangesAsync(cancellationToken); } private static ChannelTemplate NewSystemTemplate( string name, string description, int ffmpegProfileId, ChannelMusicVideoCreditsMode musicVideoCreditsMode, ChannelSongVideoMode songVideoMode, bool shuffleScheduleItems, bool randomStartPoint, int? watermarkId) => new() { Name = name, Description = description, IsSystem = true, FFmpegProfileId = ffmpegProfileId, StreamSelectorMode = ChannelStreamSelectorMode.Default, StreamSelector = string.Empty, PreferredAudioLanguageCode = string.Empty, PreferredAudioTitle = string.Empty, PlayoutSource = ChannelPlayoutSource.Generated, PlayoutMode = ChannelPlayoutMode.Continuous, StreamingMode = StreamingMode.TransportStreamHybrid, PreferredSubtitleLanguageCode = string.Empty, SubtitleMode = ChannelSubtitleMode.None, MusicVideoCreditsMode = musicVideoCreditsMode, MusicVideoCreditsTemplate = string.Empty, SongVideoMode = songVideoMode, TranscodeMode = ChannelTranscodeMode.OnDemand, IdleBehavior = ChannelIdleBehavior.StopOnDisconnect, ShuffleScheduleItems = shuffleScheduleItems, RandomStartPoint = randomStartPoint, FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible, WatermarkId = watermarkId }; }