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
1262 lines
58 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|