Files
Vocaluxe/VocaluxeSongServer/Program.cs
T
Simon DiesenreiterandClaude Opus 5.5 efcc63a640 Song server: central song library with on-demand downloads
- VocaluxeSongServer: ASP.NET Core server that scans an UltraStar library and
  serves the song list (ETag), single files (Range) and zip bundles; optional
  API key; Dockerfile and Gitea workflow for git.disi.dev/projects/vocaluxe-song-server
- Game: config section SongServer (Url, ApiKey). At song loading, the song text
  files are mirrored into a local cache that is loaded like a song folder;
  without connection the last library is used
- VocaluxeLib: CSongFiles hook for all song file access; media are downloaded on
  demand (covers in the background, preview audio, everything before singing
  with progress in the sing screen)
- Tests for scanner, API and downloads (incl. resume); AGENTS.md for the fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:23:00 +01:00

127 lines
4.6 KiB
C#

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;
var builder = WebApplication.CreateBuilder(args);
// Settings: appsettings.json section "Library", or environment variables Library__Path, Library__ApiKey, ...
builder.Services.Configure<CLibraryOptions>(builder.Configuration.GetSection("Library"));
builder.Services.AddSingleton<CLibraryScanner>();
builder.Services.AddSingleton<CLibraryIndex>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CLibraryIndex>());
builder.Services.AddResponseCompression(o =>
{
o.EnableForHttps = true;
o.Providers.Add<GzipCompressionProvider>();
o.Providers.Add<BrotliCompressionProvider>();
});
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
var app = builder.Build();
app.UseResponseCompression();
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<IOptions<CLibraryOptions>>().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");
});
app.Run();
public partial class Program { }