using System.IO.Compression; using System.Text.Json; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using VocaluxeSongServer.Contracts; using VocaluxeSongServer.Library; using VocaluxeSongServer.Sources; var builder = WebApplication.CreateBuilder(args); // Settings: appsettings.json section "Library", or environment variables Library__Path, Library__ApiKey, ... builder.Services.Configure(builder.Configuration.GetSection("Library")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Song sources for search and download (see Sources/). USDB needs Usdb__User and Usdb__Password. builder.Services.AddSingleton(); builder.Services.AddHttpClient("sources", c => c.Timeout = TimeSpan.FromMinutes(10)); builder.Services.AddSingleton(sp => new CGitHubSongSource(sp.GetRequiredService().CreateClient("sources"))); builder.Services.AddSingleton(sp => new CUsdbSongSource( new HttpClient(new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), AutomaticDecompression = System.Net.DecompressionMethods.All }) { Timeout = TimeSpan.FromMinutes(2), DefaultRequestHeaders = { { "User-Agent", "VocaluxeSongServer" } } }, builder.Configuration.GetSection("Usdb").Get() ?? new CUsdbOptions(), sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddResponseCompression(o => { o.EnableForHttps = true; o.Providers.Add(); o.Providers.Add(); }); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); var app = builder.Build(); app.UseResponseCompression(); // The web UI (wwwroot): search and download songs, browse the library. Mobile first. app.UseDefaultFiles(); app.UseStaticFiles(); app.MapGet("/health", (CLibraryIndex index) => index.Current == null ? Results.Json(new { status = "scanning" }, statusCode: 503) : Results.Json(new { status = "ok", songs = index.Current.Dto.Songs.Count, version = index.Current.Dto.Version })); var api = app.MapGroup("/api/v1"); api.AddEndpointFilter(async (context, next) => { var key = context.HttpContext.RequestServices.GetRequiredService>().Value.ApiKey; if (!string.IsNullOrEmpty(key) && context.HttpContext.Request.Headers["X-Api-Key"] != key) { return Results.Problem(title: "Missing or wrong X-Api-Key", statusCode: StatusCodes.Status401Unauthorized); } return await next(context); }); // The whole library: songs and their files. Clients cache it and send If-None-Match. api.MapGet("/library", (HttpContext ctx, CLibraryIndex index) => { var snapshot = index.Current; if (snapshot == null) { return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable); } var etag = "\"" + snapshot.Dto.Version + "\""; ctx.Response.Headers.ETag = etag; if (ctx.Request.Headers.IfNoneMatch == etag) { return Results.StatusCode(StatusCodes.Status304NotModified); } return Results.Json(snapshot.Dto); }); api.MapPost("/library/rescan", (CLibraryIndex index) => { index.RequestRescan(); return Results.Accepted(); }); // One file of the library. Only files listed in the library are served (no path tricks possible). // Supports Range requests, so clients can resume downloads. var contentTypes = new FileExtensionContentTypeProvider(); api.MapGet("/files/{**path}", (string path, CLibraryIndex index) => { var snapshot = index.Current; if (snapshot == null || !snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath)) { return Results.NotFound(); } if (!contentTypes.TryGetContentType(fullPath, out var contentType)) { contentType = "application/octet-stream"; } return Results.File(fullPath, contentType, lastModified: File.GetLastWriteTimeUtc(fullPath), enableRangeProcessing: true); }); // Several (small) files at once as a zip archive, e.g. all song text files on the first sync. api.MapPost("/files/bundle", async (BundleRequest request, CLibraryIndex index, HttpContext ctx) => { var snapshot = index.Current; if (snapshot == null) { return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable); } const int maxFiles = 2000; if (request.Paths.Count > maxFiles) { return Results.Problem(title: $"At most {maxFiles} files per bundle", statusCode: StatusCodes.Status400BadRequest); } var buffer = new MemoryStream(); using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, true)) { foreach (var path in request.Paths.Distinct()) { if (!snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath)) { continue; } var entry = zip.CreateEntry(path, CompressionLevel.Fastest); entry.LastWriteTime = File.GetLastWriteTimeUtc(fullPath); await using var source = File.OpenRead(fullPath); await using var target = entry.Open(); await source.CopyToAsync(target, ctx.RequestAborted); } } buffer.Position = 0; return Results.File(buffer, "application/zip", "bundle.zip"); }); // Song search and downloads api.MapGet("/sources", (CSongSearch search) => search.Sources.Select(s => new SourceDto { Id = s.Id, Name = s.Name, Description = s.Description, Enabled = s.Enabled, Hint = s.Hint })); api.MapGet("/search", (string q, CSongSearch search, CancellationToken ct) => search.SearchAsync(q, ct)); api.MapGet("/downloads", (CDownloadQueue queue) => queue.Jobs); api.MapPost("/downloads", (DownloadRequest request, CDownloadQueue queue) => { try { return Results.Json(queue.Enqueue(request)); } catch (ArgumentException e) { return Results.Problem(title: e.Message, statusCode: StatusCodes.Status400BadRequest); } }); app.Run(); public partial class Program { }