Files
ersatztv/ErsatzTV/Startup.cs
T
timothyandClaude Fable 5.1 16a6e2790f test(563): bind the fixture with the production body binder, and name the wrapper that is left
The docstring, the decision record and docs/testing.md all claimed the replay covered everything
except two hops. MVC model binding was a third: production binds /api/* bodies with Newtonsoft
(Startup -> AddNewtonsoftJson -> CustomContractResolver + StringEnumConverter) while the replay
deserialized with System.Text.Json. Measured on this tree: for the fixture's own bodies the two
agree, but for a body omitting the `required` member "collection" they diverge -- System.Text.Json
throws, Newtonsoft binds Collection = null and the action runs. So the fixture's stated purpose
("field names and casing match what the HTTP body binder accepts") was asserted by nothing, and a
fixture production would bind differently could still go green.

Rather than only widening the residue list, bind the way production binds. The registration moves
into ErsatzTV/Serialization/ApiJsonSettings.cs, Startup applies it from there, and both
OpenApiSerializerContractTests (which had its own mirror of the settings) and the scripted replay
now call that same function -- one definition, no copies to drift.
Production_Body_Binder_Ignores_Required_Members asserts both halves of the divergence THROUGH the
replay's own Bind helper, so pointing the replayer at another serializer reddens; the fixture's own
bodies cannot witness that swap.

The residue is now named honestly in all four places: the binding WRAPPER (input formatter, the
[ApiController] automatic 400 before an action runs) is uncovered, the serializer inside it is not.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 09:25:11 +02:00

1262 lines
58 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Abstractions;
using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Security.Claims;
using System.Threading.Channels;
using System.Threading.RateLimiting;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Auth;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Auth;
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.MySql.Data;
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.Middleware;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
using ErsatzTV.Services.RunOnce;
using ErsatzTV.Services.Validators;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication;
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.RateLimiting;
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 Newtonsoft.Json;
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; }
// Parse a semicolon-separated configuration value into trimmed, non-empty entries.
private static string[] SplitConfig(string value) =>
string.IsNullOrWhiteSpace(value)
? []
: value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
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(DayOfWeek),
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();
// With both lists cleared, X-Forwarded-* is accepted from ANY peer (spoofable, issue
// #285). Restrict trust to the real reverse proxy by configuring
// ForwardedHeaders:KnownProxies (IPs) and/or ForwardedHeaders:KnownNetworks (CIDRs).
bool trustRestricted = false;
foreach (string proxy in SplitConfig(Configuration["ForwardedHeaders:KnownProxies"]))
{
if (IPAddress.TryParse(proxy, out IPAddress address))
{
options.KnownProxies.Add(address);
trustRestricted = true;
}
else
{
Log.Warning("Ignoring invalid ForwardedHeaders:KnownProxies entry {Entry}", proxy);
}
}
foreach (string network in SplitConfig(Configuration["ForwardedHeaders:KnownNetworks"]))
{
if (System.Net.IPNetwork.TryParse(network, out System.Net.IPNetwork parsed))
{
options.KnownIPNetworks.Add(parsed);
trustRestricted = true;
}
else
{
Log.Warning("Ignoring invalid ForwardedHeaders:KnownNetworks entry {Entry}", network);
}
}
if (!trustRestricted)
{
// Kept as-is from #285 (trust any peer, warn) rather than flipped to ForwardedHeaders.None:
// the forwarded scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation
// (Request.Scheme in GetChannelGuideHandler / IptvController), so ignoring them would regress
// stream URLs to http/internal-host for a proxied deployment that hasn't set KnownProxies.
// Setting ForwardedHeaders:KnownProxies/:KnownNetworks is still strongly recommended when
// exposing ErsatzTV beyond a trusted LAN — it also gives the #295 login rate limiter an
// unspoofable client IP and lets the session cookie be marked Secure behind TLS.
Log.Warning(
"ForwardedHeaders trusts X-Forwarded-* from any peer (spoofable). Set " +
"ForwardedHeaders:KnownProxies and/or ForwardedHeaders:KnownNetworks to restrict " +
"trust to your reverse proxy when exposing ErsatzTV beyond a trusted LAN.");
}
});
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;
});
// Contract-honesty transformers (#287): document the API surface the way it actually
// behaves at runtime — the ApiKey security scheme + per-operation security/401 (shared
// with the enforcing filter's predicate), a synthesized operationId for every action,
// and the 400 ValidationProblemDetails a binding failure returns. "v1" document only.
options.AddDocumentTransformer(ApiSecuritySchemeDocumentTransformer.TransformAsync);
options.AddOperationTransformer<ApiSecurityOperationTransformer>();
options.AddOperationTransformer<OperationIdOpenApiTransformer>();
options.AddOperationTransformer(ValidationProblemOperationTransformer.TransformAsync);
});
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);
// Browser SPA authentication (#295). A cookie session is ALWAYS registered — local username/password
// login and the OIDC callback both sign into it. OIDC is added only when configured; the /iptv JWT
// bearer is added only when configured. The /api surface accepts a session OR the machine X-Api-Key
// (see ApiAuthorizationFilter); real enforcement is that filter, not a DefaultPolicy.
AuthenticationBuilder authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultScheme = AuthConstants.CookieScheme;
if (OidcHelper.IsEnabled)
{
options.DefaultChallengeScheme = AuthConstants.OidcScheme;
}
})
.AddCookie(
AuthConstants.CookieScheme,
options =>
{
options.CookieManager = new ChunkingCookieManager();
options.Cookie.Name = "ctv-session";
options.Cookie.HttpOnly = true;
// Lax + no credentialed CORS keeps the cookie same-origin (the SPA is served from /app);
// this is a core CSRF defense alongside the required X-CSRF header on session mutations.
options.Cookie.SameSite = SameSiteMode.Lax;
// SameAsRequest (not Always) so a plain-HTTP LAN deployment is not locked out; behind a
// TLS-terminating proxy the app sees https once ForwardedHeaders:KnownProxies is set.
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromDays(14);
options.SlidingExpiration = true;
options.Events = new CookieAuthenticationEvents
{
// /api is an XHR surface — answer 401/403 rather than redirecting to a login page.
OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
OnRedirectToAccessDenied = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
OnValidatePrincipal = CookieSecurityStampValidator.ValidateAsync
};
});
if (OidcHelper.IsEnabled)
{
authenticationBuilder.AddOpenIdConnect(
AuthConstants.OidcScheme,
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.Scope.Add("profile");
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = new PathString("/callback");
options.SaveTokens = true;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Events = new OpenIdConnectEvents
{
// Mark the session as OIDC so the cookie stamp validator skips it (the stamp is a
// local-login concept; OIDC sessions are governed by the IdP).
OnTokenValidated = context =>
{
if (context.Principal?.Identity is ClaimsIdentity identity)
{
identity.AddClaim(new Claim(AuthConstants.AuthMethodClaim, AuthConstants.MethodOidc));
}
return Task.CompletedTask;
},
OnRedirectToIdentityProviderForSignOut = context =>
{
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
{
context.Response.Redirect(OidcHelper.LogoutUri);
context.HandleResponse();
}
return Task.CompletedTask;
}
};
});
}
if (JwtHelper.IsEnabled)
{
authenticationBuilder.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;
}
};
});
}
// Authorization is always registered now that the pipeline always runs UseAuthorization (the cookie
// scheme is always present). No DefaultPolicy: /api is gated by ApiAuthorizationFilter, and no
// endpoint carries [Authorize]. The JWT-only policy stays for /iptv's ConditionalIptvAuthorizeFilter.
services.AddAuthorization(options =>
{
if (JwtHelper.IsEnabled)
{
options.AddPolicy(
"JwtOnlyScheme",
new AuthorizationPolicyBuilder("jwt")
.RequireAuthenticatedUser()
.Build());
}
});
// Per-IP rate limit for the unauthenticated auth surface (login/setup/password) — blunts local
// password brute-force. Keyed on the connection remote IP (accurate only when
// ForwardedHeaders:KnownProxies is configured behind a proxy — see UseForwardedHeaders below).
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(
"auth",
httpContext => RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(5),
QueueLimit = 0
}));
});
services.AddCors(o => o.AddPolicy(
"ApiCors",
builder =>
{
string[] origins = SplitConfig(Configuration["Api:CorsAllowedOrigins"]);
if (origins.Length > 0)
{
// Exact-origin allowlist. Must permit the auth + optimistic-concurrency request
// headers and expose the ETag, or cross-origin API clients break (issue #284).
builder.WithOrigins(origins)
.AllowAnyMethod()
.WithHeaders(
"Accept",
"Content-Type",
"Authorization",
ApiAuthorizationFilter.HeaderName,
ApiAuthorizationFilter.CsrfHeaderName,
"If-Match")
.WithExposedHeaders("ETag");
// Note: AllowCredentials is deliberately NOT set — cross-origin cookie auth is impossible
// by design (a CSRF defense). Cross-origin clients authenticate with X-Api-Key.
}
// No configured origins => no cross-origin access (the SPA is same-origin from /app).
}));
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<ApiAuthorizationFilter>();
})
// ApiJsonSettings rather than an inline lambda: the request-body binder's semantics are asserted
// by ScriptedScheduleControllerTests, which has to configure the SAME object, not a mirror of it.
.AddNewtonsoftJson(opt => ApiJsonSettings.Apply(opt.SerializerSettings));
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
// API-key authorization for the JSON API (independent of JWT/OIDC). The provider resolves the
// effective key once (config, else persisted, else generated) so writes are fail-closed.
services.AddSingleton<IApiKeyProvider, ApiKeyProvider>();
services.AddScoped<ApiAuthorizationFilter>();
// Local-admin password hashing (browser SPA session auth, #295). Stateless → singleton.
services.AddSingleton<ILocalPasswordHasher, LocalPasswordHasher>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
services.AddMemoryCache();
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";
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
TvContext.RegisterUnicodeCaseFunctions = SqliteUnicodeFunctions.Register;
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";
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
// MySQL's LOWER() is already Unicode-aware, so the facet-value handler never takes the
// custom-fold branch here; assigned explicitly so a provider switch cannot inherit SQLite's.
TvContext.RegisterUnicodeCaseFunctions = static _ => { };
}
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>();
// graphics-engine remote images (channel-logo watermarks and image elements). the client
// timeout is INFINITE on purpose: under ResponseHeadersRead the body read falls outside
// HttpClient.Timeout, so HttpRemoteImageFetcher owns the deadline with a linked CTS that
// covers headers AND body. the fetcher re-asserts that on the client it gets, so losing
// this line widens nothing; what is ONLY configurable here is the redirect cap, since it
// lives on the handler. redirects stay enabled (logo hosts and CDNs use them) but are
// capped well below the default 50 hops. (ersatztv#511)
services.AddHttpClient(HttpRemoteImageFetcher.HttpClientName)
.ConfigureHttpClient(c => c.Timeout = Timeout.InfiniteTimeSpan)
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 3
});
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.UseMiddleware<SecurityHeadersMiddleware>();
// Resolve the API key eagerly so a generated key is created + logged at startup rather than
// on the first API request.
app.ApplicationServices.GetRequiredService<IApiKeyProvider>();
app.UseCors("ApiCors");
app.UseForwardedHeaders();
//app.UseHttpLogging();
app.UseSerilogRequestLogging(options =>
{
// Keep the query out of the built-in RequestPath; a scrubbed copy (access_token redacted) is
// enriched below as RequestPathScrubbed and referenced by the message template instead (#559).
options.IncludeQueryInRequestPath = false;
// 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/v1/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"]);
diagnosticContext.Set("RequestPathScrubbed", RequestLogScrubber.ScrubbedPath(httpContext.Request));
};
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPathScrubbed} 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),
legacy =>
{
// ersatztv#91 phase (b): the legacy Blazor Server UI has been removed. This
// branch now only (a) 302-redirects retired Blazor routes to their /app
// equivalents and (b) co-hosts the REST API controllers, /docs (Scalar) and
// (in Development) the OpenAPI document — everything served here that is not the
// SPA (/app) or IPTV (/iptv).
// 302 (not 301): 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. AppendQueryString merges the incoming query into the
// target: a target that already carries its own query (e.g.
// "/app/media?kind=movies") gets '&'-joined instead of a malformed double '?'.
legacy.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 + LegacyUiRedirects.AppendQueryString(target, context.Request.QueryString));
return;
}
}
await next(context);
});
// ersatztv#286: version the API surface at /api/v1. Legacy unversioned /api/* callers
// (curl, the MCP server, bookmarks) are rewritten in-pipeline to /api/v1/* — a rewrite,
// not a redirect, so method/body/auth survive — carrying RFC 8594 Deprecation/Sunset
// headers. Must run before UseRouting so the rewritten path matches the versioned routes.
legacy.UseMiddleware<ApiVersionRewriteMiddleware>();
legacy.UseRouting();
// Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and
// /docs. UseAuthentication populates HttpContext.User from the cookie (default scheme) — the
// credential ApiAuthorizationFilter accepts alongside the machine X-Api-Key — and lets the
// OIDC middleware intercept /callback. UseAuthorization is required for the middleware to run
// (no [Authorize] endpoints; /api is gated by ApiAuthorizationFilter, /iptv by its own
// ConditionalIptvAuthorizeFilter). UseRateLimiter enforces the "auth" per-IP policy on the
// login/setup/password endpoints.
legacy.UseAuthentication();
legacy.UseAuthorization();
legacy.UseRateLimiter();
legacy.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
// ersatztv#91 phase (b): with the Blazor host page gone, any request that
// reached this branch without matching a controller or a LegacyUiRedirects
// entry is a retired Blazor route — 302 it to the SPA so no legacy path
// hard-404s. /api, /artwork, /docs and /openapi are excluded: an unmatched
// one of those is a genuine 404, not a UI route (this branch does not
// prefix-guard them, so the fallback must — see docs/decisions.md #204).
endpoints.MapFallback(context =>
{
PathString path = context.Request.Path;
if (path.StartsWithSegments("/api") ||
path.StartsWithSegments("/artwork") ||
path.StartsWithSegments("/docs") ||
path.StartsWithSegments("/openapi"))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
}
context.Response.Redirect(context.Request.PathBase + "/app");
return Task.CompletedTask;
});
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<IRemoteStreamProber, HttpRemoteStreamProber>();
services.AddScoped<IRemoteImageFetcher, HttpRemoteImageFetcher>();
services.AddScoped<IRemoteImageValidator, RemoteImageValidator>();
services.AddScoped<IRemoteLogoCacher, RemoteLogoCacher>();
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<IJellyfinMusicVideoRepository, JellyfinMusicVideoRepository>();
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<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>();
// Waits on SystemStartup.WaitForDatabase before seeding (#295 env seed) — the migrator is a
// BackgroundService, so registration order alone does not guarantee the schema exists.
services.AddHostedService<LocalAdminSeedService>();
services.AddHostedService<DatabaseCleanerService>();
// One-time migration of existing external-URL channel logos into the image cache (ersatztv#525).
// It awaits SystemStartup.WaitForDatabase itself, so the schema is guaranteed; registration
// order relative to the other hosted services is not load-bearing (it touches only logo artwork).
services.AddHostedService<ExternalLogoMigratorService>();
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");
}
}
}