Files
ersatztv/ErsatzTV/Startup.cs
T
timothyandClaude Fable 5 f26790aa35 fix(api): mirror runtime Newtonsoft JSON casing in OpenAPI spec (#198)
The OpenAPI document is generated from System.Text.Json metadata, whose
camelCase drifted from the runtime MVC serializer (Newtonsoft via
CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId"
and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy
special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty]
override). That fed the SPA the wrong keys.

Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered
on all three documents that renames each object schema's Properties (and
Required) keys through the SAME Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. Regenerate v1.json.

Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs
through the runtime Newtonsoft settings and pins the v1.json schema property
sets to the emitted keys, failing if generation drifts again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:00:23 +02:00

1067 lines
44 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Abstractions;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using BlazorSortable;
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.Images;
using ErsatzTV.Core.Interfaces.Database;
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.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Core.Trakt;
using ErsatzTV.Core.Troubleshooting;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Filters;
using ErsatzTV.Formatters;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Database;
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.Streaming.Graphics;
using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
using ErsatzTV.Services.RunOnce;
using ErsatzTV.Services.Validators;
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.Localization;
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 Microsoft.OpenApi;
using MudBlazor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Refit;
using Scalar.AspNetCore;
using Serilog;
using Serilog.Events;
using Testably.Abstractions;
namespace ErsatzTV;
public class Startup
{
public Startup(IConfiguration configuration, IWebHostEnvironment env)
{
Configuration = configuration;
CurrentEnvironment = env;
}
public IConfiguration Configuration { get; }
private IWebHostEnvironment CurrentEnvironment { get; }
private static void UseStringEnumSchemas(OpenApiDocument document)
{
if (document.Components?.Schemas is null)
{
return;
}
// Core's own enums get scanned wholesale. A couple of API response DTOs (settings endpoints)
// also expose enums from lower layers Core is allowed to depend on (ErsatzTV.FFmpeg) or from
// Serilog; list those individually instead of Assembly.GetTypes()-scanning their assemblies,
// since eagerly loading every type in ErsatzTV.FFmpeg (e.g. NvEncSharp-backed types) can throw
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.Concat([
typeof(FFmpeg.OutputFormat.OutputFormatKind),
typeof(FFmpeg.State.WatermarkLocation),
typeof(FFmpeg.State.WatermarkSize),
typeof(Serilog.Events.LogEventLevel)
])
.GroupBy(type => type.Name)
.ToDictionary(group => group.Key, group => group.First());
foreach ((string schemaName, IOpenApiSchema schema) in document.Components.Schemas)
{
if (schema is not OpenApiSchema openApiSchema ||
!enumTypes.TryGetValue(schemaName, out Type enumType))
{
continue;
}
openApiSchema.Type = JsonSchemaType.String;
openApiSchema.Format = null;
openApiSchema.Enum = Enum.GetNames(enumType)
.Select(name => JsonValue.Create(name))
.Cast<JsonNode>()
.ToList();
}
}
[SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")]
public void ConfigureServices(IServiceCollection services)
{
BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get<BugsnagConfiguration>();
services.Configure<BugsnagConfiguration>(Configuration.GetSection("Bugsnag"));
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.All;
options.ForwardLimit = 2;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder));
services.AddOpenApi(
"v1",
options =>
{
options.ShouldInclude += a => a.GroupName == "general";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
options.AddDocumentTransformer((document, _, _) =>
{
UseStringEnumSchemas(document);
return Task.CompletedTask;
});
});
services.AddOpenApi(
"scripted-schedule-tagged",
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
});
services.AddOpenApi(
"scripted-schedule",
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
options.AddSchemaTransformer(NewtonsoftSchemaNamingTransformer.TransformAsync);
var tag = new OpenApiTag { Name = "ScriptedSchedule" };
var tagReference = new OpenApiTagReference("ScriptedSchedule");
options.AddOperationTransformer((operation, _, _) =>
{
operation.Tags.Clear();
operation.Tags.Add(tagReference);
return Task.CompletedTask;
});
options.AddDocumentTransformer((document, _, _) =>
{
document.Tags.Clear();
document.Tags.Add(tag);
return Task.CompletedTask;
});
});
services.ConfigureHttpJsonOptions(o => o.SerializerOptions.NumberHandling = JsonNumberHandling.Strict);
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());
options.Filters.AddService<ApiKeyAuthorizationFilter>();
})
.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"));
// optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
services.AddScoped<ApiKeyAuthorizationFilter>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
services.AddMemoryCache();
services.AddRazorPages(options =>
{
if (OidcHelper.IsEnabled)
{
options.Conventions.AuthorizeFolder("/");
}
});
services.AddServerSideBlazor()
.AddHubOptions(hubOptions => hubOptions.MaximumReceiveMessageSize = 1024 * 1024);
services.AddMudServices();
services.AddSortable();
var coreAssembly = Assembly.GetAssembly(typeof(LibraryScanProgress));
if (coreAssembly != null)
{
services.AddCourier(coreAssembly);
}
Console.OutputEncoding = Encoding.UTF8;
string etvVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
Log.Logger.Information("ErsatzTV version {Version}", etvVersion);
Log.Logger.Warning(
"Report bugs to {GitHub} or contact us at {Contact}",
"https://github.com/ErsatzTV/ErsatzTV",
"https://ersatztv.org/contact");
CopyMacOsConfigFolderIfNeeded();
List<string> directoriesToCreate =
[
FileSystemLayout.AppDataFolder,
FileSystemLayout.TranscodeFolder,
FileSystemLayout.TempFilePoolFolder,
FileSystemLayout.FontsCacheFolder,
FileSystemLayout.TemplatesFolder,
FileSystemLayout.MusicVideoCreditsTemplatesFolder,
FileSystemLayout.ChannelStreamSelectorsFolder,
FileSystemLayout.ChannelGuideTemplatesFolder,
FileSystemLayout.GraphicsElementsTemplatesFolder,
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
FileSystemLayout.GraphicsElementsImageTemplatesFolder,
FileSystemLayout.GraphicsElementsScriptTemplatesFolder,
FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder,
FileSystemLayout.GraphicsElementsMotionTemplatesFolder,
FileSystemLayout.ScriptsFolder,
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
FileSystemLayout.AudioStreamSelectorScriptsFolder,
FileSystemLayout.MpegTsScriptsFolder,
FileSystemLayout.DefaultMpegTsScriptFolder
];
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<string>("MySql:ConnectionString");
services.AddDbContext<TvContext>(
options =>
{
if (databaseProvider == Provider.Sqlite.Name)
{
TvContext.IsSqlite = true;
options.UseSqlite(
sqliteConnectionString,
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite");
});
}
if (databaseProvider == Provider.MySql.Name)
{
TvContext.IsSqlite = false;
options.UseMySql(
mySqlConnectionString,
ServerVersion.AutoDetect(mySqlConnectionString),
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql");
}
);
}
},
ServiceLifetime.Scoped,
ServiceLifetime.Singleton);
services.AddDbContextFactory<TvContext>(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";
}
Log.Logger.Information("Transcode folder is {Folder}", FileSystemLayout.TranscodeFolder);
services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining<GetAllChannels>());
services.AddRefitClient<IPlexTvApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2"));
services.AddRefitClient<ITraktApi>(
new RefitSettings
{
ContentSerializer = new NewtonsoftJsonContentSerializer(
new JsonSerializerSettings
{
ContractResolver = new SnakeCasePropertyNamesContractResolver()
})
})
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri("https://api.trakt.tv");
c.DefaultRequestHeaders.Add("User-Agent", $"ErsatzTV/{etvVersion}");
});
services.AddHttpClient("RefitCustomClient").AddHttpMessageHandler<SlowApiHandler>();
services.Configure<TraktConfiguration>(Configuration.GetSection("Trakt"));
services.AddResponseCompression(options => { options.EnableForHttps = true; });
CustomServices(services);
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
string baseUrl = SystemEnvironment.BaseUrl;
if (!string.IsNullOrWhiteSpace(baseUrl))
{
try
{
app.UsePathBase(baseUrl);
// for testing - make path base required
// app.Use(async (context, next) =>
// {
// if (context.Request.PathBase != baseUrl)
// {
// context.Response.StatusCode = 404;
// return;
// }
//
// await next(context);
// });
}
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;
}
if (httpContext.Request.Path.ToUriComponent().StartsWith(
"/api",
StringComparison.OrdinalIgnoreCase) &&
!httpContext.Request.Path.ToUriComponent().StartsWith(
"/api/scan",
StringComparison.OrdinalIgnoreCase))
{
return LogEventLevel.Debug;
}
return LogEventLevel.Verbose;
};
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]);
};
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.00} ms from {UserAgent} at {RemoteIP}";
});
app.UseRequestLocalization(options =>
{
CultureInfo[] cinfo = CultureInfo.GetCultures(CultureTypes.AllCultures & ~CultureTypes.NeutralCultures);
string[] supportedCultures = cinfo.Select(t => t.Name).Distinct().ToArray();
options.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(Localization.UiCultures)
.SetDefaultCulture(Localization.DefaultCulture);
options.AddInitialRequestCultureProvider(
new DatabaseRequestCultureProvider(
new AcceptLanguageHeaderRequestCultureProvider()));
});
app.UseStaticFiles();
var extensionProvider = new FileExtensionContentTypeProvider();
// fix static file M3U8 mime type
extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl");
// fix static file TS mime type
extensionProvider.Mappings.Remove(".ts");
extensionProvider.Mappings.Add(".ts", "video/mp2t");
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<IFFmpegWorkerRequest> writer = app.ApplicationServices
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
},
// to serve m4s
ServeUnknownFileTypes = true
});
app.UseResponseCompression();
app.Use(async (context, next) =>
{
if (!context.Request.Host.Value.StartsWith("localhost", StringComparison.OrdinalIgnoreCase) &&
!IsIptvPath(context.Request.Path) &&
context.Connection.LocalPort != Settings.UiPort)
{
context.Response.StatusCode = 404;
return;
}
await next(context);
});
app.MapWhen(
ctx => ctx.Request.Path.StartsWithSegments("/app"),
spa =>
{
string spaStaticFileRoot = SpaStaticFileRoot();
IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot)
? new PhysicalFileProvider(spaStaticFileRoot)
: new NullFileProvider();
spa.UseStaticFiles(
new StaticFileOptions
{
FileProvider = spaFileProvider,
RequestPath = "/app"
});
spa.Run(
async context =>
{
Microsoft.Extensions.FileProviders.IFileInfo appIndexFile =
spaFileProvider.GetFileInfo("index.html");
if (!appIndexFile.Exists)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength = appIndexFile.Length;
await using Stream stream = appIndexFile.CreateReadStream();
await stream.CopyToAsync(context.Response.Body);
});
});
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
blazor =>
{
// ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by
// redirecting migrated legacy Blazor routes to their /app equivalents.
// 302 (not 301): this map grows as pages migrate, and permanent-redirect
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
// only rewrites the request side (Request.Path/PathBase); it never touches
// redirect Location headers, so the PathBase prefix must be re-applied here.
blazor.Use(async (context, next) =>
{
if (HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method))
{
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
{
context.Response.Redirect(
context.Request.PathBase + target + context.Request.QueryString);
return;
}
}
await next(context);
});
blazor.UseRouting();
if (OidcHelper.IsEnabled)
{
blazor.UseAuthentication();
#pragma warning disable ASP0001
blazor.UseAuthorization();
#pragma warning restore ASP0001
}
blazor.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
if (CurrentEnvironment.IsDevelopment())
{
endpoints.MapOpenApi();
}
endpoints.MapScalarApiReference("/docs", options =>
{
options.AddDocument(
"scripted-schedule",
"Scripted Schedule",
"openapi/scripted-schedule-tagged.json");
options.AddDocument("v1", "General", "openapi/v1.json");
options.HideClientButton = true;
options.DocumentDownloadType = DocumentDownloadType.None;
options.Title = "ErsatzTV API Reference";
});
});
});
app.MapWhen(
ctx => IsIptvPath(ctx.Request.Path),
iptv =>
{
iptv.UseRouting();
iptv.UseEndpoints(endpoints => endpoints.MapControllers());
});
return;
bool IsIptvPath(PathString path)
{
return path.StartsWithSegments("/iptv") ||
path.StartsWithSegments("/discover.json") ||
path.StartsWithSegments("/device.xml") ||
path.StartsWithSegments("/lineup.json") ||
path.StartsWithSegments("/lineup_status.json");
}
bool IsSpaPath(PathString path) => path.StartsWithSegments("/app");
string SpaStaticFileRoot()
{
string webRootPath = CurrentEnvironment.WebRootPath ??
Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot");
return Path.Combine(webRootPath, "app");
}
}
private static void CustomServices(IServiceCollection services)
{
services.AddSingleton<IEnvironmentValidator, EnvironmentValidator>();
services.AddSingleton<IFileSystem, RealFileSystem>();
services.AddSingleton<IDatabaseMigrations, DatabaseMigrations>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchTargets, SearchTargets>();
services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>();
services.AddSingleton<SearchQueryParser>();
services.AddSingleton<ITroubleshootingNotifier, TroubleshootingNotifier>();
services.AddSingleton<ITroubleshootingPlaybackStatusStore, TroubleshootingPlaybackStatusStore>();
services.AddSingleton<CustomFontMapper>();
services.AddSingleton<GraphicsEngineFonts>();
services.AddSingleton(Program.InMemoryLogService);
if (SearchHelper.IsElasticSearchEnabled)
{
Log.Logger.Information("Using Elasticsearch (external) search index backend");
ElasticSearchIndex.Uri = new Uri(SearchHelper.ElasticSearchUri);
ElasticSearchIndex.IndexName = SearchHelper.ElasticSearchIndexName;
services.AddSingleton<ISearchIndex, ElasticSearchIndex>();
}
else
{
Log.Logger.Information("Using Lucene (embedded) search index backend");
services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
}
services.AddSingleton<IScannerProxyService, ScannerProxyService>();
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
services.AddSingleton<IDirectStreamSessionTracker, DirectStreamSessionTracker>();
services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
services.AddSingleton<RecyclableMemoryStreamManager>();
services.AddSingleton<SystemStartup>();
services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);
AddChannel<IEmbyBackgroundServiceRequest>(services);
AddChannel<IFFmpegWorkerRequest>(services);
AddChannel<ISearchIndexBackgroundServiceRequest>(services);
AddChannel<IScannerBackgroundServiceRequest>(services);
services.AddScoped<IMacOsConfigFolderHealthCheck, MacOsConfigFolderHealthCheck>();
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
services.AddScoped<IFFmpegCapabilitiesHealthCheck, FFmpegCapabilitiesHealthCheck>();
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
services.AddScoped<IHardwareAccelerationHealthCheck, HardwareAccelerationHealthCheck>();
services.AddScoped<IMovieMetadataHealthCheck, MovieMetadataHealthCheck>();
services.AddScoped<IEpisodeMetadataHealthCheck, EpisodeMetadataHealthCheck>();
services.AddScoped<IZeroDurationHealthCheck, ZeroDurationHealthCheck>();
services.AddScoped<IFileNotFoundHealthCheck, FileNotFoundHealthCheck>();
services.AddScoped<IUnavailableHealthCheck, UnavailableHealthCheck>();
services.AddScoped<IVaapiDriverHealthCheck, VaapiDriverHealthCheck>();
services.AddScoped<IUnifiedDockerHealthCheck, UnifiedDockerHealthCheck>();
services.AddScoped<IDowngradeHealthCheck, DowngradeHealthCheck>();
services.AddScoped<IEmptyScheduleHealthCheck, EmptyScheduleHealthCheck>();
services.AddScoped<IHealthCheckService, HealthCheckService>();
services.AddScoped<IChannelRepository, ChannelRepository>();
services.AddScoped<IFFmpegProfileRepository, FFmpegProfileRepository>();
services.AddScoped<IMediaSourceRepository, MediaSourceRepository>();
services.AddScoped<IMediaItemRepository, MediaItemRepository>();
services.AddScoped<IMediaCollectionRepository, MediaCollectionRepository>();
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ISongRepository, SongRepository>();
services.AddScoped<IImageRepository, ImageRepository>();
services.AddScoped<IRemoteStreamRepository, RemoteStreamRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>();
services.AddScoped<ICollectionEtag, CollectionEtag>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>();
services.AddScoped<IExternalJsonPlayoutItemProvider, ExternalJsonPlayoutItemProvider>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IBlockPlayoutBuilder, BlockPlayoutBuilder>();
services.AddScoped<IBlockPlayoutPreviewBuilder, BlockPlayoutPreviewBuilder>();
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>();
services.AddScoped<ISequentialPlayoutBuilder, SequentialPlayoutBuilder>();
services.AddScoped<IScriptedPlayoutBuilder, ScriptedPlayoutBuilder>();
services.AddScoped<ISchedulingEngine, SchedulingEngine>();
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>();
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>();
services.AddScoped<IImageCache, ImageCache>();
services.AddScoped<ILocalFileSystem, LocalFileSystem>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>();
services.AddScoped<IPlexMetadataRepository, PlexMetadataRepository>();
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>();
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
services.AddScoped<IJellyfinMovieRepository, JellyfinMovieRepository>();
services.AddScoped<IEmbyApiClient, EmbyApiClient>();
services.AddScoped<IEmbyPathReplacementService, EmbyPathReplacementService>();
services.AddScoped<IEmbyTelevisionRepository, EmbyTelevisionRepository>();
services.AddScoped<IEmbyCollectionRepository, EmbyCollectionRepository>();
services.AddScoped<IEmbyMovieRepository, EmbyMovieRepository>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<ICustomStreamSelector, CustomStreamSelector>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory,
MultiEpisodeShuffleCollectionEnumeratorFactory>();
services.AddScoped<IRerunHelper, RerunHelper>();
services.AddScoped<IChannelLogoGenerator, ChannelLogoGenerator>();
services.AddScoped<IGraphicsEngine, GraphicsEngine>();
services.AddScoped<IGraphicsElementRepository, GraphicsElementRepository>();
services.AddScoped<ITemplateDataRepository, TemplateDataRepository>();
services.AddScoped<IGraphicsElementLoader, GraphicsElementLoader>();
services.AddScoped<TemplateFunctions>();
services.AddScoped<IDecoSelector, DecoSelector>();
services.AddScoped<IWatermarkSelector, WatermarkSelector>();
services.AddScoped<IGraphicsElementSelector, GraphicsElementSelector>();
services.AddScoped<IHlsInitSegmentCache, HlsInitSegmentCache>();
services.AddScoped<IMpegTsScriptService, MpegTsScriptService>();
services.AddScoped<ILanguageCodeService, LanguageCodeService>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();
services.AddScoped<FFmpegProcessService>();
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
services.AddScoped<IMusicVideoCreditsGenerator, MusicVideoCreditsGenerator>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(_ =>
{
var sanitizer = new HtmlSanitizer();
sanitizer.AllowedAttributes.Add("class");
return sanitizer;
});
services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>();
services.AddScoped<IEmbySecretStore, EmbySecretStore>();
services.AddScoped<IScriptEngine, ScriptEngine>();
services.AddScoped<ISequentialScheduleValidator, SequentialScheduleValidator>();
services.AddScoped<PlexEtag>();
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));
services.AddTransient<SlowApiHandler>();
services.AddTransient<SlowQueryInterceptor>();
// run-once/blocking startup services
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();
services.AddHostedService<DatabaseCleanerService>();
services.AddHostedService<LoadLoggingLevelService>();
services.AddHostedService<CacheCleanerService>();
services.AddHostedService<ResourceExtractorService>();
services.AddHostedService<PlatformSettingsService>();
services.AddHostedService<RebuildSearchIndexService>();
services.AddHostedService<RunHealthChecksService>();
// background services
#if !DEBUG_NO_SYNC
services.AddHostedService<EmbyService>();
services.AddHostedService<JellyfinService>();
services.AddHostedService<PlexService>();
services.AddHostedService<ScannerService>();
#endif
services.AddHostedService<FFmpegLocatorService>();
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<SearchIndexService>();
}
private static void AddChannel<TMessageType>(IServiceCollection services)
{
services.AddSingleton(
Channel.CreateUnbounded<TMessageType>(new UnboundedChannelOptions { SingleReader = true }));
services.AddSingleton(provider => provider.GetRequiredService<Channel<TMessageType>>().Reader);
services.AddSingleton(provider => provider.GetRequiredService<Channel<TMessageType>>().Writer);
}
private static void CopyMacOsConfigFolderIfNeeded()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
return;
}
bool newDbExists = File.Exists(FileSystemLayout.DatabasePath);
if (newDbExists)
{
return;
}
bool oldDbExists = File.Exists(FileSystemLayout.MacOsOldDatabasePath);
if (!oldDbExists)
{
return;
}
// safe to move here since
// - old db exists
// - new db does not exist
Log.Logger.Information(
"Migrating config data from {OldFolder} to {NewFolder}",
FileSystemLayout.MacOsOldAppDataFolder,
FileSystemLayout.AppDataFolder);
try
{
// delete new config folder
if (Directory.Exists(FileSystemLayout.AppDataFolder))
{
Directory.Delete(FileSystemLayout.AppDataFolder, true);
}
// move old config folder to new config folder
Directory.Move(FileSystemLayout.MacOsOldAppDataFolder, FileSystemLayout.AppDataFolder);
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Failed to migrate config data");
}
}
}