using System.Reflection; using System.Text; using System.Threading.Channels; using Bugsnag.AspNet.Core; using Dapper; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Streaming; using ErsatzTV.Core; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Errors; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Health; using ErsatzTV.Core.Health.Checks; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.GitHub; using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata.Nfo; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Trakt; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.GitHub; using ErsatzTV.Infrastructure.Health; using ErsatzTV.Infrastructure.Health.Checks; using ErsatzTV.Infrastructure.Images; using ErsatzTV.Infrastructure.Jellyfin; using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Serialization; using ErsatzTV.Services; using ErsatzTV.Services.RunOnce; using FluentValidation.AspNetCore; using Ganss.XSS; using MediatR; using MediatR.Courier.DependencyInjection; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; using MudBlazor.Services; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Refit; using Serilog; namespace ErsatzTV; public class Startup { public Startup(IConfiguration configuration, IWebHostEnvironment env) { Configuration = configuration; CurrentEnvironment = env; } public IConfiguration Configuration { get; } private IWebHostEnvironment CurrentEnvironment { get; } public void ConfigureServices(IServiceCollection services) { BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get(); services.Configure(Configuration.GetSection("Bugsnag")); services.AddBugsnag( configuration => { configuration.ApiKey = bugsnagConfig.ApiKey; configuration.ProjectNamespaces = new[] { "ErsatzTV" }; configuration.AppVersion = Assembly.GetEntryAssembly() ?.GetCustomAttribute() ?.InformationalVersion ?? "unknown"; configuration.AutoNotify = false; configuration.NotifyReleaseStages = new[] { "public", "develop" }; #if DEBUG configuration.ReleaseStage = "develop"; #else // effectively "disable" by tweaking app config configuration.ReleaseStage = bugsnagConfig.Enable ? "public" : "private"; #endif }); services.AddCors( o => o.AddPolicy( "AllowAll", builder => { builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); })); services.AddControllers( options => { options.OutputFormatters.Insert(0, new ConcatPlaylistOutputFormatter()); options.OutputFormatters.Insert(0, new ChannelPlaylistOutputFormatter()); options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter()); options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter()); options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter()); }) .AddNewtonsoftJson( opt => { opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; opt.SerializerSettings.ContractResolver = new CustomContractResolver(); opt.SerializerSettings.Converters.Add(new StringEnumConverter()); }) .AddFluentValidation( options => { options.RegisterValidatorsFromAssemblyContaining(); options.ImplicitlyValidateChildProperties = true; }); if (!CurrentEnvironment.IsDevelopment()) { services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2"); } services.AddMemoryCache(); services.AddRazorPages(); services.AddServerSideBlazor(); services.AddMudServices(); var coreAssembly = Assembly.GetAssembly(typeof(LibraryScanProgress)); if (coreAssembly != null) { services.AddCourier(coreAssembly); } Console.OutputEncoding = Encoding.UTF8; Log.Logger.Information( "ErsatzTV version {Version}", Assembly.GetEntryAssembly()?.GetCustomAttribute() ?.InformationalVersion ?? "unknown"); Log.Logger.Warning("This is beta software and may be unstable"); Log.Logger.Warning( "Give feedback at {GitHub} or {Discord}", "https://github.com/jasongdove/ErsatzTV", "https://discord.gg/hHaJm3yGy6"); if (!Directory.Exists(FileSystemLayout.AppDataFolder)) { Directory.CreateDirectory(FileSystemLayout.AppDataFolder); } if (!Directory.Exists(FileSystemLayout.TranscodeFolder)) { Directory.CreateDirectory(FileSystemLayout.TranscodeFolder); } if (!Directory.Exists(FileSystemLayout.TempFilePoolFolder)) { Directory.CreateDirectory(FileSystemLayout.TempFilePoolFolder); } if (!Directory.Exists(FileSystemLayout.FontsCacheFolder)) { Directory.CreateDirectory(FileSystemLayout.FontsCacheFolder); } Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); // until we add a setting for a file-specific scheme://host:port to access // stream urls contained in this file, it doesn't make sense to do // for now, continue to use scheme and host from incoming requests // string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml"); // Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath); var connectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;"; services.AddDbContext( options => options.UseSqlite( connectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure"); }), ServiceLifetime.Scoped, ServiceLifetime.Singleton); services.AddDbContextFactory( options => options.UseSqlite( connectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure"); })); SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); SqlMapper.AddTypeHandler(new GuidHandler()); SqlMapper.AddTypeHandler(new TimeSpanHandler()); var logConnectionString = $"Data Source={FileSystemLayout.LogDatabasePath}"; services.AddDbContext( options => options.UseSqlite(logConnectionString), ServiceLifetime.Scoped, ServiceLifetime.Singleton); services.AddDbContextFactory( options => options.UseSqlite(logConnectionString)); services.AddMediatR(typeof(GetAllChannels).Assembly); services.AddRefitClient() .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2")); services.AddRefitClient() .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.trakt.tv")); services.Configure(Configuration.GetSection("Trakt")); CustomServices(services); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseCors("AllowAll"); // app.UseSerilogRequestLogging(); app.UseStaticFiles(); var extensionProvider = new FileExtensionContentTypeProvider(); extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl"); app.UseStaticFiles( new StaticFileOptions { FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder), RequestPath = "/iptv/session", ContentTypeProvider = extensionProvider, OnPrepareResponse = ctx => { // Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath); ChannelWriter writer = app.ApplicationServices .GetRequiredService>(); writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath)); } }); app.UseRouting(); if (!env.IsDevelopment()) { app.Map( "/v2", app2 => { if (string.IsNullOrWhiteSpace(env.WebRootPath)) { env.WebRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); } app2.UseRouting(); app2.UseEndpoints(e => e.MapFallbackToFile("index.html")); app2.UseFileServer( new FileServerOptions { FileProvider = new PhysicalFileProvider(Path.Combine(env.WebRootPath, "v2")) }); }); } app.UseEndpoints( endpoints => { endpoints.MapControllers(); endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); // if (env.IsDevelopment()) // { // endpoints.MapToVueCliProxy( // "/v2/{*path}", // new SpaOptions { SourcePath = "client-app" }, // "serve", // regex: "Compiled successfully", // forceKill: true); // } }); } private void CustomServices(IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); AddChannel(services); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped( _ => { var sanitizer = new HtmlSanitizer(); sanitizer.AllowedAttributes.Add("class"); return sanitizer; }); services.AddScoped(); services.AddScoped(); services.AddScoped(); // services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>)); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); } private void AddChannel(IServiceCollection services) { services.AddSingleton( Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true })); services.AddSingleton( provider => provider.GetRequiredService>().Reader); services.AddSingleton( provider => provider.GetRequiredService>().Writer); } }