mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
931 lines
38 KiB
931 lines
38 KiB
using System.Diagnostics.CodeAnalysis; |
|
using System.Globalization; |
|
using System.IO.Abstractions; |
|
using System.Reflection; |
|
using System.Runtime.InteropServices; |
|
using System.Text; |
|
using System.Text.Json.Serialization; |
|
using System.Threading.Channels; |
|
using BlazorSortable; |
|
using Bugsnag.AspNet.Core; |
|
using Dapper; |
|
using ErsatzTV.Application; |
|
using ErsatzTV.Application.Channels; |
|
using ErsatzTV.Application.Streaming; |
|
using ErsatzTV.Core; |
|
using ErsatzTV.Core.Emby; |
|
using ErsatzTV.Core.Errors; |
|
using ErsatzTV.Core.FFmpeg; |
|
using ErsatzTV.Core.Health; |
|
using ErsatzTV.Core.Health.Checks; |
|
using ErsatzTV.Core.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.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.Data; |
|
using ErsatzTV.Infrastructure.Data.Repositories; |
|
using ErsatzTV.Infrastructure.Database; |
|
using ErsatzTV.Infrastructure.Emby; |
|
using ErsatzTV.Infrastructure.FFmpeg; |
|
using ErsatzTV.Infrastructure.GitHub; |
|
using ErsatzTV.Infrastructure.Health; |
|
using ErsatzTV.Infrastructure.Health.Checks; |
|
using ErsatzTV.Infrastructure.Images; |
|
using ErsatzTV.Infrastructure.Jellyfin; |
|
using ErsatzTV.Infrastructure.Locking; |
|
using ErsatzTV.Infrastructure.Metadata; |
|
using ErsatzTV.Infrastructure.Plex; |
|
using ErsatzTV.Infrastructure.Runtime; |
|
using ErsatzTV.Infrastructure.Scheduling; |
|
using ErsatzTV.Infrastructure.Scripting; |
|
using ErsatzTV.Infrastructure.Search; |
|
using ErsatzTV.Infrastructure.Sqlite.Data; |
|
using ErsatzTV.Infrastructure.Streaming; |
|
using ErsatzTV.Infrastructure.Streaming.Graphics; |
|
using ErsatzTV.Infrastructure.Trakt; |
|
using ErsatzTV.Serialization; |
|
using ErsatzTV.Services; |
|
using ErsatzTV.Services.RunOnce; |
|
using ErsatzTV.Services.Validators; |
|
using FluentValidation; |
|
using FluentValidation.AspNetCore; |
|
using Ganss.Xss; |
|
using MediatR.Courier.DependencyInjection; |
|
using Microsoft.AspNetCore.Authentication.Cookies; |
|
using Microsoft.AspNetCore.Authentication.JwtBearer; |
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect; |
|
using Microsoft.AspNetCore.Authorization; |
|
using Microsoft.AspNetCore.DataProtection; |
|
using Microsoft.AspNetCore.HttpOverrides; |
|
using Microsoft.AspNetCore.StaticFiles; |
|
using Microsoft.EntityFrameworkCore; |
|
using Microsoft.Extensions.FileProviders; |
|
using Microsoft.Extensions.Primitives; |
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect; |
|
using Microsoft.IdentityModel.Tokens; |
|
using Microsoft.IO; |
|
using Microsoft.OpenApi; |
|
using MudBlazor.Services; |
|
using Newtonsoft.Json; |
|
using Newtonsoft.Json.Converters; |
|
using Refit; |
|
using Scalar.AspNetCore; |
|
using Serilog; |
|
using Serilog.Events; |
|
using Testably.Abstractions; |
|
|
|
namespace ErsatzTV; |
|
|
|
public class Startup |
|
{ |
|
public Startup(IConfiguration configuration, IWebHostEnvironment env) |
|
{ |
|
Configuration = configuration; |
|
CurrentEnvironment = env; |
|
} |
|
|
|
public IConfiguration Configuration { get; } |
|
|
|
private IWebHostEnvironment CurrentEnvironment { get; } |
|
|
|
[SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] |
|
public void ConfigureServices(IServiceCollection services) |
|
{ |
|
BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get<BugsnagConfiguration>(); |
|
services.Configure<BugsnagConfiguration>(Configuration.GetSection("Bugsnag")); |
|
services.Configure<ForwardedHeadersOptions>(options => |
|
{ |
|
options.ForwardedHeaders = ForwardedHeaders.All; |
|
options.ForwardLimit = 2; |
|
options.KnownIPNetworks.Clear(); |
|
options.KnownProxies.Clear(); |
|
}); |
|
|
|
services.AddBugsnag(configuration => |
|
{ |
|
configuration.ApiKey = bugsnagConfig.ApiKey; |
|
configuration.ProjectNamespaces = new[] { "ErsatzTV" }; |
|
configuration.AppVersion = Assembly.GetEntryAssembly() |
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>() |
|
?.InformationalVersion ?? "unknown"; |
|
configuration.AutoNotify = true; |
|
|
|
configuration.NotifyReleaseStages = new[] { "public", "develop" }; |
|
|
|
#if DEBUG || DEBUG_NO_SYNC |
|
configuration.ReleaseStage = "develop"; |
|
#else |
|
// effectively "disable" by tweaking app config |
|
configuration.ReleaseStage = bugsnagConfig.Enable ? "public" : "private"; |
|
#endif |
|
}); |
|
|
|
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder)); |
|
|
|
services.AddOpenApi("v1", options => { options.ShouldInclude += a => a.GroupName == "general"; }); |
|
|
|
services.AddOpenApi( |
|
"scripted-schedule-tagged", |
|
options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; }); |
|
|
|
services.AddOpenApi( |
|
"scripted-schedule", |
|
options => |
|
{ |
|
options.ShouldInclude += a => a.GroupName == "scripted-schedule"; |
|
var tag = new OpenApiTag { Name = "ScriptedSchedule" }; |
|
var tagReference = new OpenApiTagReference("ScriptedSchedule"); |
|
options.AddOperationTransformer((operation, _, _) => |
|
{ |
|
operation.Tags.Clear(); |
|
operation.Tags.Add(tagReference); |
|
return Task.CompletedTask; |
|
}); |
|
options.AddDocumentTransformer((document, _, _) => |
|
{ |
|
document.Tags.Clear(); |
|
document.Tags.Add(tag); |
|
return Task.CompletedTask; |
|
}); |
|
}); |
|
|
|
services.ConfigureHttpJsonOptions(o => o.SerializerOptions.NumberHandling = JsonNumberHandling.Strict); |
|
|
|
OidcHelper.Init(Configuration); |
|
JwtHelper.Init(Configuration); |
|
SearchHelper.Init(Configuration); |
|
|
|
if (OidcHelper.IsEnabled) |
|
{ |
|
services.AddAuthentication(options => |
|
{ |
|
options.DefaultScheme = "cookie"; |
|
options.DefaultChallengeScheme = "oidc"; |
|
}) |
|
.AddCookie( |
|
"cookie", |
|
options => |
|
{ |
|
options.CookieManager = new ChunkingCookieManager(); |
|
|
|
options.Cookie.HttpOnly = true; |
|
options.Cookie.SameSite = SameSiteMode.None; |
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; |
|
}) |
|
.AddOpenIdConnect( |
|
"oidc", |
|
options => |
|
{ |
|
options.Authority = OidcHelper.Authority; |
|
options.ClientId = OidcHelper.ClientId; |
|
options.ClientSecret = OidcHelper.ClientSecret; |
|
|
|
options.ResponseType = OpenIdConnectResponseType.Code; |
|
options.UsePkce = true; |
|
options.ResponseMode = OpenIdConnectResponseMode.Query; |
|
|
|
options.Scope.Clear(); |
|
options.Scope.Add("openid"); |
|
|
|
options.CallbackPath = new PathString("/callback"); |
|
|
|
options.SaveTokens = true; |
|
|
|
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; |
|
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; |
|
|
|
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri)) |
|
{ |
|
options.Events = new OpenIdConnectEvents |
|
{ |
|
OnRedirectToIdentityProviderForSignOut = context => |
|
{ |
|
context.Response.Redirect(OidcHelper.LogoutUri); |
|
context.HandleResponse(); |
|
|
|
return Task.CompletedTask; |
|
} |
|
}; |
|
} |
|
}); |
|
} |
|
|
|
if (JwtHelper.IsEnabled) |
|
{ |
|
services.AddAuthentication().AddJwtBearer( |
|
"jwt", |
|
options => |
|
{ |
|
options.TokenValidationParameters = new TokenValidationParameters |
|
{ |
|
ValidateIssuer = false, |
|
ValidateAudience = false, |
|
ValidateIssuerSigningKey = true, |
|
IssuerSigningKey = JwtHelper.IssuerSigningKey, |
|
ValidateLifetime = true |
|
}; |
|
options.Events = new JwtBearerEvents |
|
{ |
|
OnMessageReceived = static context => |
|
{ |
|
StringValues token = context.Request.Query["access_token"]; |
|
if (!string.IsNullOrWhiteSpace(token)) |
|
{ |
|
context.Token = token; |
|
} |
|
|
|
return Task.CompletedTask; |
|
} |
|
}; |
|
}); |
|
} |
|
|
|
if (OidcHelper.IsEnabled || JwtHelper.IsEnabled) |
|
{ |
|
services.AddAuthorization(options => |
|
{ |
|
if (OidcHelper.IsEnabled) |
|
{ |
|
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder( |
|
"cookie", |
|
"oidc"); |
|
|
|
defaultAuthorizationPolicyBuilder = |
|
defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser(); |
|
|
|
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build(); |
|
} |
|
|
|
if (JwtHelper.IsEnabled) |
|
{ |
|
var onlyJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("jwt"); |
|
options.AddPolicy( |
|
"JwtOnlyScheme", |
|
onlyJwtSchemePolicyBuilder |
|
.RequireAuthenticatedUser() |
|
.Build()); |
|
} |
|
} |
|
); |
|
} |
|
|
|
services.AddCors(o => o.AddPolicy( |
|
"AllowAll", |
|
builder => |
|
{ |
|
builder.AllowAnyOrigin() |
|
.AllowAnyMethod() |
|
.AllowAnyHeader(); |
|
})); |
|
|
|
services.AddLocalization(); |
|
|
|
services.AddControllers(options => |
|
{ |
|
options.OutputFormatters.Insert(0, new ConcatPlaylistOutputFormatter()); |
|
options.OutputFormatters.Insert(0, new ChannelPlaylistOutputFormatter()); |
|
options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter()); |
|
options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter()); |
|
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter()); |
|
}) |
|
.AddNewtonsoftJson(opt => |
|
{ |
|
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; |
|
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; |
|
opt.SerializerSettings.ContractResolver = new CustomContractResolver(); |
|
opt.SerializerSettings.Converters.Add(new StringEnumConverter()); |
|
}); |
|
|
|
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme")); |
|
|
|
services.AddFluentValidationAutoValidation(); |
|
services.AddValidatorsFromAssemblyContaining<Startup>(); |
|
|
|
services.AddMemoryCache(); |
|
|
|
services.AddRazorPages(options => |
|
{ |
|
if (OidcHelper.IsEnabled) |
|
{ |
|
options.Conventions.AuthorizeFolder("/"); |
|
} |
|
}); |
|
|
|
services.AddServerSideBlazor() |
|
.AddHubOptions(hubOptions => hubOptions.MaximumReceiveMessageSize = 1024 * 1024); |
|
|
|
services.AddMudServices(); |
|
|
|
services.AddSortable(); |
|
|
|
var coreAssembly = Assembly.GetAssembly(typeof(LibraryScanProgress)); |
|
if (coreAssembly != null) |
|
{ |
|
services.AddCourier(coreAssembly); |
|
} |
|
|
|
Console.OutputEncoding = Encoding.UTF8; |
|
|
|
string etvVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>() |
|
?.InformationalVersion ?? "unknown"; |
|
|
|
Log.Logger.Information("ErsatzTV version {Version}", etvVersion); |
|
|
|
Log.Logger.Warning( |
|
"Give feedback at {GitHub} or {Discord}", |
|
"https://github.com/ErsatzTV/ErsatzTV", |
|
"https://discord.ersatztv.org"); |
|
|
|
CopyMacOsConfigFolderIfNeeded(); |
|
|
|
List<string> directoriesToCreate = |
|
[ |
|
FileSystemLayout.AppDataFolder, |
|
FileSystemLayout.TranscodeFolder, |
|
FileSystemLayout.TempFilePoolFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
FileSystemLayout.TemplatesFolder, |
|
FileSystemLayout.MusicVideoCreditsTemplatesFolder, |
|
FileSystemLayout.ChannelStreamSelectorsFolder, |
|
FileSystemLayout.ChannelGuideTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsTextTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsImageTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsScriptTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsSubtitleTemplatesFolder, |
|
FileSystemLayout.GraphicsElementsMotionTemplatesFolder, |
|
FileSystemLayout.ScriptsFolder, |
|
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder, |
|
FileSystemLayout.AudioStreamSelectorScriptsFolder, |
|
FileSystemLayout.MpegTsScriptsFolder, |
|
FileSystemLayout.DefaultMpegTsScriptFolder |
|
]; |
|
|
|
foreach (string directory in directoriesToCreate) |
|
{ |
|
if (directory is not null && !Directory.Exists(directory)) |
|
{ |
|
Directory.CreateDirectory(directory); |
|
} |
|
} |
|
|
|
// until we add a setting for a file-specific scheme://host:port to access |
|
// stream urls contained in this file, it doesn't make sense to do |
|
// for now, continue to use scheme and host from incoming requests |
|
// string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml"); |
|
// Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath); |
|
|
|
string databaseProvider = Configuration.GetValue("provider", Provider.Sqlite.Name); |
|
var sqliteConnectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;"; |
|
string mySqlConnectionString = Configuration.GetValue<string>("MySql:ConnectionString"); |
|
|
|
services.AddDbContext<TvContext>( |
|
options => |
|
{ |
|
if (databaseProvider == Provider.Sqlite.Name) |
|
{ |
|
TvContext.IsSqlite = true; |
|
|
|
options.UseSqlite( |
|
sqliteConnectionString, |
|
o => |
|
{ |
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); |
|
o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); |
|
}); |
|
} |
|
|
|
if (databaseProvider == Provider.MySql.Name) |
|
{ |
|
TvContext.IsSqlite = false; |
|
|
|
options.UseMySql( |
|
mySqlConnectionString, |
|
ServerVersion.AutoDetect(mySqlConnectionString), |
|
o => |
|
{ |
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); |
|
o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); |
|
} |
|
); |
|
} |
|
}, |
|
ServiceLifetime.Scoped, |
|
ServiceLifetime.Singleton); |
|
|
|
services.AddDbContextFactory<TvContext>(options => |
|
{ |
|
if (databaseProvider == Provider.Sqlite.Name) |
|
{ |
|
options.UseSqlite( |
|
sqliteConnectionString, |
|
o => |
|
{ |
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); |
|
o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"); |
|
}); |
|
} |
|
|
|
if (databaseProvider == Provider.MySql.Name) |
|
{ |
|
options.UseMySql( |
|
mySqlConnectionString, |
|
ServerVersion.AutoDetect(mySqlConnectionString), |
|
o => |
|
{ |
|
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); |
|
o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"); |
|
} |
|
); |
|
} |
|
}); |
|
|
|
if (databaseProvider == Provider.Sqlite.Name) |
|
{ |
|
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath); |
|
|
|
TvContext.LastInsertedRowId = "last_insert_rowid()"; |
|
TvContext.CaseInsensitiveCollation = "NOCASE"; |
|
|
|
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); |
|
SqlMapper.AddTypeHandler(new GuidHandler()); |
|
SqlMapper.AddTypeHandler(new TimeSpanHandler()); |
|
} |
|
|
|
if (databaseProvider == Provider.MySql.Name) |
|
{ |
|
TvContext.LastInsertedRowId = "last_insert_id()"; |
|
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci"; |
|
} |
|
|
|
Log.Logger.Information("Transcode folder is {Folder}", FileSystemLayout.TranscodeFolder); |
|
|
|
services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining<GetAllChannels>()); |
|
|
|
services.AddRefitClient<IPlexTvApi>() |
|
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://plex.tv/api/v2")); |
|
|
|
services.AddRefitClient<ITraktApi>( |
|
new RefitSettings |
|
{ |
|
ContentSerializer = new NewtonsoftJsonContentSerializer( |
|
new JsonSerializerSettings |
|
{ |
|
ContractResolver = new SnakeCasePropertyNamesContractResolver() |
|
}) |
|
}) |
|
.ConfigureHttpClient(c => |
|
{ |
|
c.BaseAddress = new Uri("https://api.trakt.tv"); |
|
c.DefaultRequestHeaders.Add("User-Agent", $"ErsatzTV/{etvVersion}"); |
|
}); |
|
|
|
services.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); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Log.Error( |
|
ex, |
|
"Failed to configure ETV_BASE_URL; please check syntax and include leading slash e.g. `/etv`: {BaseUrl}", |
|
baseUrl); |
|
} |
|
} |
|
|
|
app.UseCors("AllowAll"); |
|
app.UseForwardedHeaders(); |
|
|
|
//app.UseHttpLogging(); |
|
app.UseSerilogRequestLogging(options => |
|
{ |
|
options.IncludeQueryInRequestPath = true; |
|
|
|
// Emit debug-level events instead of the defaults |
|
options.GetLevel = (httpContext, elapsed, ex) => |
|
{ |
|
if (ex is not null) |
|
{ |
|
return LogEventLevel.Error; |
|
} |
|
|
|
if (httpContext.Response.StatusCode > 499) |
|
{ |
|
return LogEventLevel.Error; |
|
} |
|
|
|
if (httpContext.Request.Path.ToUriComponent().StartsWith( |
|
"/iptv", |
|
StringComparison.OrdinalIgnoreCase)) |
|
{ |
|
return LogEventLevel.Debug; |
|
} |
|
|
|
if (httpContext.Request.Path.ToUriComponent().StartsWith( |
|
"/api", |
|
StringComparison.OrdinalIgnoreCase) && |
|
!httpContext.Request.Path.ToUriComponent().StartsWith( |
|
"/api/scan", |
|
StringComparison.OrdinalIgnoreCase)) |
|
{ |
|
return LogEventLevel.Debug; |
|
} |
|
|
|
return LogEventLevel.Verbose; |
|
}; |
|
|
|
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => |
|
{ |
|
diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress); |
|
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"]); |
|
}; |
|
|
|
options.MessageTemplate = |
|
"HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.00} ms from {UserAgent} at {RemoteIP}"; |
|
}); |
|
|
|
app.UseRequestLocalization(options => |
|
{ |
|
CultureInfo[] cinfo = CultureInfo.GetCultures(CultureTypes.AllCultures & ~CultureTypes.NeutralCultures); |
|
string[] supportedCultures = cinfo.Select(t => t.Name).Distinct().ToArray(); |
|
options.AddSupportedCultures(supportedCultures) |
|
.AddSupportedUICultures(supportedCultures) |
|
.SetDefaultCulture("en-US"); |
|
}); |
|
|
|
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 => !IsIptvPath(ctx.Request.Path), |
|
blazor => |
|
{ |
|
blazor.UseRouting(); |
|
|
|
if (OidcHelper.IsEnabled) |
|
{ |
|
blazor.UseAuthentication(); |
|
#pragma warning disable ASP0001 |
|
blazor.UseAuthorization(); |
|
#pragma warning restore ASP0001 |
|
} |
|
|
|
blazor.UseEndpoints(endpoints => |
|
{ |
|
endpoints.MapControllers(); |
|
endpoints.MapBlazorHub(); |
|
endpoints.MapFallbackToPage("/_Host"); |
|
|
|
if (CurrentEnvironment.IsDevelopment()) |
|
{ |
|
endpoints.MapOpenApi(); |
|
} |
|
|
|
endpoints.MapScalarApiReference("/docs", options => |
|
{ |
|
options.AddDocument( |
|
"scripted-schedule", |
|
"Scripted Schedule", |
|
"openapi/scripted-schedule-tagged.json"); |
|
options.AddDocument("v1", "General", "openapi/v1.json"); |
|
options.HideClientButton = true; |
|
options.DocumentDownloadType = DocumentDownloadType.None; |
|
options.Title = "ErsatzTV API Reference"; |
|
}); |
|
}); |
|
}); |
|
|
|
app.MapWhen( |
|
ctx => IsIptvPath(ctx.Request.Path), |
|
iptv => |
|
{ |
|
iptv.UseRouting(); |
|
iptv.UseEndpoints(endpoints => endpoints.MapControllers()); |
|
}); |
|
return; |
|
|
|
bool IsIptvPath(PathString path) |
|
{ |
|
return path.StartsWithSegments("/iptv") || |
|
path.StartsWithSegments("/discover.json") || |
|
path.StartsWithSegments("/device.xml") || |
|
path.StartsWithSegments("/lineup.json") || |
|
path.StartsWithSegments("/lineup_status.json"); |
|
} |
|
} |
|
|
|
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<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<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<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<IErrorReportsHealthCheck, ErrorReportsHealthCheck>(); |
|
services.AddScoped<IUnifiedDockerHealthCheck, UnifiedDockerHealthCheck>(); |
|
services.AddScoped<IDowngradeHealthCheck, DowngradeHealthCheck>(); |
|
services.AddScoped<IHealthCheckService, HealthCheckService>(); |
|
|
|
services.AddScoped<IChannelRepository, ChannelRepository>(); |
|
services.AddScoped<IFFmpegProfileRepository, FFmpegProfileRepository>(); |
|
services.AddScoped<IMediaSourceRepository, MediaSourceRepository>(); |
|
services.AddScoped<IMediaItemRepository, MediaItemRepository>(); |
|
services.AddScoped<IMediaCollectionRepository, MediaCollectionRepository>(); |
|
services.AddScoped<IConfigElementRepository, ConfigElementRepository>(); |
|
services.AddScoped<ITelevisionRepository, TelevisionRepository>(); |
|
services.AddScoped<ISearchRepository, SearchRepository>(); |
|
services.AddScoped<IMovieRepository, MovieRepository>(); |
|
services.AddScoped<IArtistRepository, ArtistRepository>(); |
|
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>(); |
|
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>(); |
|
services.AddScoped<ISongRepository, SongRepository>(); |
|
services.AddScoped<IImageRepository, ImageRepository>(); |
|
services.AddScoped<IRemoteStreamRepository, RemoteStreamRepository>(); |
|
services.AddScoped<ILibraryRepository, LibraryRepository>(); |
|
services.AddScoped<IMetadataRepository, MetadataRepository>(); |
|
services.AddScoped<IArtworkRepository, ArtworkRepository>(); |
|
services.AddScoped<ICollectionEtag, CollectionEtag>(); |
|
services.AddScoped<IFFmpegLocator, FFmpegLocator>(); |
|
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>(); |
|
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>(); |
|
services.AddScoped<IExternalJsonPlayoutItemProvider, ExternalJsonPlayoutItemProvider>(); |
|
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>(); |
|
services.AddScoped<IBlockPlayoutBuilder, BlockPlayoutBuilder>(); |
|
services.AddScoped<IBlockPlayoutPreviewBuilder, BlockPlayoutPreviewBuilder>(); |
|
services.AddScoped<IBlockPlayoutFillerBuilder, BlockPlayoutFillerBuilder>(); |
|
services.AddScoped<ISequentialPlayoutBuilder, SequentialPlayoutBuilder>(); |
|
services.AddScoped<IScriptedPlayoutBuilder, ScriptedPlayoutBuilder>(); |
|
services.AddScoped<ISchedulingEngine, SchedulingEngine>(); |
|
services.AddScoped<IExternalJsonPlayoutBuilder, ExternalJsonPlayoutBuilder>(); |
|
services.AddScoped<IPlayoutTimeShifter, PlayoutTimeShifter>(); |
|
services.AddScoped<IImageCache, ImageCache>(); |
|
services.AddScoped<ILocalFileSystem, LocalFileSystem>(); |
|
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>(); |
|
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>(); |
|
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>(); |
|
services.AddScoped<IPlexCollectionRepository, PlexCollectionRepository>(); |
|
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>(); |
|
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>(); |
|
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>(); |
|
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>(); |
|
services.AddScoped<IJellyfinMovieRepository, JellyfinMovieRepository>(); |
|
services.AddScoped<IEmbyApiClient, EmbyApiClient>(); |
|
services.AddScoped<IEmbyPathReplacementService, EmbyPathReplacementService>(); |
|
services.AddScoped<IEmbyTelevisionRepository, EmbyTelevisionRepository>(); |
|
services.AddScoped<IEmbyCollectionRepository, EmbyCollectionRepository>(); |
|
services.AddScoped<IEmbyMovieRepository, EmbyMovieRepository>(); |
|
services.AddScoped<IRuntimeInfo, RuntimeInfo>(); |
|
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>(); |
|
services.AddScoped<ICustomStreamSelector, CustomStreamSelector>(); |
|
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>(); |
|
services.AddScoped<IStreamSelectorRepository, StreamSelectorRepository>(); |
|
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>(); |
|
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory, |
|
MultiEpisodeShuffleCollectionEnumeratorFactory>(); |
|
services.AddScoped<IRerunHelper, RerunHelper>(); |
|
services.AddScoped<IChannelLogoGenerator, ChannelLogoGenerator>(); |
|
services.AddScoped<IGraphicsEngine, GraphicsEngine>(); |
|
services.AddScoped<IGraphicsElementRepository, GraphicsElementRepository>(); |
|
services.AddScoped<ITemplateDataRepository, TemplateDataRepository>(); |
|
services.AddScoped<IGraphicsElementLoader, GraphicsElementLoader>(); |
|
services.AddScoped<TemplateFunctions>(); |
|
services.AddScoped<IDecoSelector, DecoSelector>(); |
|
services.AddScoped<IWatermarkSelector, WatermarkSelector>(); |
|
services.AddScoped<IGraphicsElementSelector, GraphicsElementSelector>(); |
|
services.AddScoped<IHlsInitSegmentCache, HlsInitSegmentCache>(); |
|
services.AddScoped<IMpegTsScriptService, MpegTsScriptService>(); |
|
services.AddScoped<ILanguageCodeService, LanguageCodeService>(); |
|
|
|
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>(); |
|
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>(); |
|
services.AddScoped<FFmpegProcessService>(); |
|
|
|
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>(); |
|
services.AddScoped<IMusicVideoCreditsGenerator, MusicVideoCreditsGenerator>(); |
|
services.AddScoped<IGitHubApiClient, GitHubApiClient>(); |
|
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(_ => |
|
{ |
|
var sanitizer = new HtmlSanitizer(); |
|
sanitizer.AllowedAttributes.Add("class"); |
|
return sanitizer; |
|
}); |
|
services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>(); |
|
services.AddScoped<IEmbySecretStore, EmbySecretStore>(); |
|
services.AddScoped<IScriptEngine, ScriptEngine>(); |
|
services.AddScoped<ISequentialScheduleValidator, SequentialScheduleValidator>(); |
|
|
|
services.AddScoped<PlexEtag>(); |
|
|
|
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>)); |
|
|
|
// run-once/blocking startup services |
|
services.AddHostedService<EndpointValidatorService>(); |
|
services.AddHostedService<DatabaseMigratorService>(); |
|
services.AddHostedService<DatabaseCleanerService>(); |
|
services.AddHostedService<LoadLoggingLevelService>(); |
|
services.AddHostedService<CacheCleanerService>(); |
|
services.AddHostedService<ResourceExtractorService>(); |
|
services.AddHostedService<PlatformSettingsService>(); |
|
services.AddHostedService<RebuildSearchIndexService>(); |
|
services.AddHostedService<RunHealthChecksService>(); |
|
|
|
// background services |
|
#if !DEBUG_NO_SYNC |
|
services.AddHostedService<EmbyService>(); |
|
services.AddHostedService<JellyfinService>(); |
|
services.AddHostedService<PlexService>(); |
|
services.AddHostedService<ScannerService>(); |
|
#endif |
|
services.AddHostedService<FFmpegLocatorService>(); |
|
services.AddHostedService<WorkerService>(); |
|
services.AddHostedService<SchedulerService>(); |
|
services.AddHostedService<FFmpegWorkerService>(); |
|
services.AddHostedService<SearchIndexService>(); |
|
} |
|
|
|
private static void AddChannel<TMessageType>(IServiceCollection services) |
|
{ |
|
services.AddSingleton( |
|
Channel.CreateUnbounded<TMessageType>(new UnboundedChannelOptions { SingleReader = true })); |
|
services.AddSingleton(provider => provider.GetRequiredService<Channel<TMessageType>>().Reader); |
|
services.AddSingleton(provider => provider.GetRequiredService<Channel<TMessageType>>().Writer); |
|
} |
|
|
|
private static void CopyMacOsConfigFolderIfNeeded() |
|
{ |
|
if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) |
|
{ |
|
return; |
|
} |
|
|
|
bool newDbExists = File.Exists(FileSystemLayout.DatabasePath); |
|
if (newDbExists) |
|
{ |
|
return; |
|
} |
|
|
|
bool oldDbExists = File.Exists(FileSystemLayout.MacOsOldDatabasePath); |
|
if (!oldDbExists) |
|
{ |
|
return; |
|
} |
|
|
|
// safe to move here since |
|
// - old db exists |
|
// - new db does not exist |
|
|
|
Log.Logger.Information( |
|
"Migrating config data from {OldFolder} to {NewFolder}", |
|
FileSystemLayout.MacOsOldAppDataFolder, |
|
FileSystemLayout.AppDataFolder); |
|
|
|
try |
|
{ |
|
// delete new config folder |
|
if (Directory.Exists(FileSystemLayout.AppDataFolder)) |
|
{ |
|
Directory.Delete(FileSystemLayout.AppDataFolder, true); |
|
} |
|
|
|
// move old config folder to new config folder |
|
Directory.Move(FileSystemLayout.MacOsOldAppDataFolder, FileSystemLayout.AppDataFolder); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Log.Logger.Warning(ex, "Failed to migrate config data"); |
|
} |
|
} |
|
}
|
|
|