using System.Diagnostics.CodeAnalysis; using System.Globalization; 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.Plex; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Trakt; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.Filters; using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Data.Repositories.Caching; using ErsatzTV.Infrastructure.Emby; using ErsatzTV.Infrastructure.FFmpeg; 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.Metadata; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Infrastructure.Runtime; using ErsatzTV.Infrastructure.Scheduling; using ErsatzTV.Infrastructure.Scripting; using ErsatzTV.Infrastructure.Search; using ErsatzTV.Infrastructure.Sqlite.Data; using ErsatzTV.Infrastructure.Streaming; using ErsatzTV.Infrastructure.Trakt; using ErsatzTV.Serialization; using ErsatzTV.Services; using ErsatzTV.Services.RunOnce; using FluentValidation; using FluentValidation.AspNetCore; using Ganss.Xss; using MediatR.Courier.DependencyInjection; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.IO; using MudBlazor.Services; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Refit; using Serilog; using Serilog.Events; namespace ErsatzTV; public class Startup { public Startup(IConfiguration configuration, IWebHostEnvironment env) { Configuration = configuration; CurrentEnvironment = env; } public IConfiguration Configuration { get; } private IWebHostEnvironment CurrentEnvironment { get; } [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] public void ConfigureServices(IServiceCollection services) { BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get(); services.Configure(Configuration.GetSection("Bugsnag")); services.Configure( options => { options.ForwardedHeaders = ForwardedHeaders.All; options.ForwardLimit = 2; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); services.AddBugsnag( configuration => { configuration.ApiKey = bugsnagConfig.ApiKey; configuration.ProjectNamespaces = new[] { "ErsatzTV" }; configuration.AppVersion = Assembly.GetEntryAssembly() ?.GetCustomAttribute() ?.InformationalVersion ?? "unknown"; configuration.AutoNotify = true; configuration.NotifyReleaseStages = new[] { "public", "develop" }; #if DEBUG || DEBUG_NO_SYNC configuration.ReleaseStage = "develop"; #else // effectively "disable" by tweaking app config configuration.ReleaseStage = bugsnagConfig.Enable ? "public" : "private"; #endif }); services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); OidcHelper.Init(Configuration); JwtHelper.Init(Configuration); SearchHelper.Init(Configuration); if (OidcHelper.IsEnabled) { services.AddAuthentication( options => { options.DefaultScheme = "cookie"; options.DefaultChallengeScheme = "oidc"; }) .AddCookie( "cookie", options => { options.CookieManager = new ChunkingCookieManager(); options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.None; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; }) .AddOpenIdConnect( "oidc", options => { options.Authority = OidcHelper.Authority; options.ClientId = OidcHelper.ClientId; options.ClientSecret = OidcHelper.ClientSecret; options.ResponseType = OpenIdConnectResponseType.Code; options.UsePkce = true; options.ResponseMode = OpenIdConnectResponseMode.Query; options.Scope.Clear(); options.Scope.Add("openid"); options.CallbackPath = new PathString("/callback"); options.SaveTokens = true; options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri)) { options.Events = new OpenIdConnectEvents { OnRedirectToIdentityProviderForSignOut = context => { context.Response.Redirect(OidcHelper.LogoutUri); context.HandleResponse(); return Task.CompletedTask; } }; } }); } if (JwtHelper.IsEnabled) { services.AddAuthentication().AddJwtBearer( "jwt", options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateIssuerSigningKey = true, IssuerSigningKey = JwtHelper.IssuerSigningKey, ValidateLifetime = true }; options.Events = new JwtBearerEvents { OnMessageReceived = static context => { StringValues token = context.Request.Query["access_token"]; if (!string.IsNullOrWhiteSpace(token)) { context.Token = token; } return Task.CompletedTask; } }; }); } if (OidcHelper.IsEnabled || JwtHelper.IsEnabled) { services.AddAuthorization( options => { if (OidcHelper.IsEnabled) { var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder( "cookie", "oidc"); defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser(); options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build(); } if (JwtHelper.IsEnabled) { var onlyJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("jwt"); options.AddPolicy( "JwtOnlyScheme", onlyJwtSchemePolicyBuilder .RequireAuthenticatedUser() .Build()); } } ); } services.AddCors( o => o.AddPolicy( "AllowAll", builder => { builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); })); services.AddLocalization(); 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()); }); services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme")); services.AddFluentValidationAutoValidation(); services.AddValidatorsFromAssemblyContaining(); services.AddMemoryCache(); services.AddRazorPages( options => { if (OidcHelper.IsEnabled) { options.Conventions.AuthorizeFolder("/"); } }); services.AddServerSideBlazor() .AddHubOptions(hubOptions => hubOptions.MaximumReceiveMessageSize = 1024 * 1024); 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/ErsatzTV/ErsatzTV", "https://discord.gg/hHaJm3yGy6"); List directoriesToCreate = [ FileSystemLayout.AppDataFolder, FileSystemLayout.TranscodeFolder, FileSystemLayout.TempFilePoolFolder, FileSystemLayout.FontsCacheFolder, FileSystemLayout.TemplatesFolder, FileSystemLayout.MusicVideoCreditsTemplatesFolder, FileSystemLayout.ChannelGuideTemplatesFolder, FileSystemLayout.ScriptsFolder, FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, FileSystemLayout.AudioStreamSelectorScriptsFolder ]; foreach (string directory in directoriesToCreate) { if (directory is not null && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } } // 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); string databaseProvider = Configuration.GetValue("provider", Provider.Sqlite.Name); var sqliteConnectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;"; string mySqlConnectionString = Configuration.GetValue("MySql:ConnectionString"); services.AddDbContext( options => { if (databaseProvider == Provider.Sqlite.Name) { options.UseSqlite( sqliteConnectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); }); } if (databaseProvider == Provider.MySql.Name) { options.UseMySql( mySqlConnectionString, ServerVersion.AutoDetect(mySqlConnectionString), o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); } ); } }, ServiceLifetime.Scoped, ServiceLifetime.Singleton); services.AddDbContextFactory( options => { if (databaseProvider == Provider.Sqlite.Name) { options.UseSqlite( sqliteConnectionString, o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); }); } if (databaseProvider == Provider.MySql.Name) { options.UseMySql( mySqlConnectionString, ServerVersion.AutoDetect(mySqlConnectionString), o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); } ); } }); if (databaseProvider == Provider.Sqlite.Name) { Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); TvContext.LastInsertedRowId = "last_insert_rowid()"; TvContext.CaseInsensitiveCollation = "NOCASE"; SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); SqlMapper.AddTypeHandler(new GuidHandler()); SqlMapper.AddTypeHandler(new TimeSpanHandler()); } if (databaseProvider == Provider.MySql.Name) { TvContext.LastInsertedRowId = "last_insert_id()"; TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci"; } services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining()); 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) { string baseUrl = Environment.GetEnvironmentVariable("ETV_BASE_URL"); if (!string.IsNullOrWhiteSpace(baseUrl)) { try { app.UsePathBase(baseUrl); } catch (Exception ex) { Log.Error( ex, "Failed to configure ETV_BASE_URL; please check syntax and include leading slash e.g. `/etv`: {BaseUrl}", baseUrl); } } app.UseCors("AllowAll"); app.UseForwardedHeaders(); // app.UseHttpLogging(); app.UseSerilogRequestLogging( options => { options.IncludeQueryInRequestPath = true; // Emit debug-level events instead of the defaults options.GetLevel = (httpContext, elapsed, ex) => { if (ex is not null) { return LogEventLevel.Error; } if (httpContext.Response.StatusCode > 499) { return LogEventLevel.Error; } if (httpContext.Request.Path.ToUriComponent().StartsWith( "/iptv", StringComparison.OrdinalIgnoreCase)) { return LogEventLevel.Debug; } return LogEventLevel.Verbose; }; }); app.UseRequestLocalization( options => { CultureInfo[] cinfo = CultureInfo.GetCultures(CultureTypes.AllCultures & ~CultureTypes.NeutralCultures); string[] supportedCultures = cinfo.Select(t => t.Name).Distinct().ToArray(); options.AddSupportedCultures(supportedCultures) .AddSupportedUICultures(supportedCultures) .SetDefaultCulture("en-US"); }); app.UseStaticFiles(); var extensionProvider = new FileExtensionContentTypeProvider(); extensionProvider.Mappings.Add(".m3u8", "application/x-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)); } // to serve m4s // ServeUnknownFileTypes = true }); app.UseRouting(); if (OidcHelper.IsEnabled) { app.UseAuthentication(); app.UseAuthorization(); } app.UseEndpoints( endpoints => { endpoints.MapControllers(); endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); }); } private static void CustomServices(IServiceCollection services) { services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); if (SearchHelper.IsElasticSearchEnabled) { Log.Logger.Information("Using Elasticsearch (external) search index backend"); ElasticSearchIndex.Uri = new Uri(SearchHelper.ElasticSearchUri); ElasticSearchIndex.IndexName = SearchHelper.ElasticSearchIndexName; services.AddSingleton(); } else { Log.Logger.Information("Using Lucene (embedded) search index backend"); services.AddSingleton(); } services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); AddChannel(services); 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(); services.AddScoped(); services.AddScoped( _ => { var sanitizer = new HtmlSanitizer(); sanitizer.AllowedAttributes.Add("class"); return sanitizer; }); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>)); // run-once/blocking startup services services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); // background services #if !DEBUG_NO_SYNC services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); #endif services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); } private static void AddChannel(IServiceCollection services) { services.AddSingleton( Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true })); services.AddSingleton( provider => provider.GetRequiredService>().Reader); services.AddSingleton( provider => provider.GetRequiredService>().Writer); } }