- Sources: "free" (Creative Commons songs with media from github.com/UltraStar-Deluxe/songs) and "usdb" (usdb.animux.de with an account; audio/video via yt-dlp from the song's #VIDEO hints or YouTube links in the comments, covers from fanart.tv/YouTube) - Aggregated search (all sources in parallel, in-library flag), download queue into <library>/Downloads/<source>/<Artist - Title>, then rescan - Web UI in wwwroot (plain JS, no build): search, downloads with progress, library; phone first, dark mode, installable - Docker image with yt-dlp, ffmpeg and deno; library mounted writable - Tests for matching, USDB parsing and song rewriting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.5 KiB
C#
168 lines
6.5 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;
|
|
using VocaluxeSongServer.Sources;
|
|
|
|
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>());
|
|
// Song sources for search and download (see Sources/). USDB needs Usdb__User and Usdb__Password.
|
|
builder.Services.AddSingleton<CYtDlp>();
|
|
builder.Services.AddHttpClient("sources", c => c.Timeout = TimeSpan.FromMinutes(10));
|
|
builder.Services.AddSingleton<ISongSource>(sp => new CGitHubSongSource(sp.GetRequiredService<IHttpClientFactory>().CreateClient("sources")));
|
|
builder.Services.AddSingleton<ISongSource>(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<CUsdbOptions>() ?? new CUsdbOptions(),
|
|
sp.GetRequiredService<CYtDlp>(), sp.GetRequiredService<ILogger<CUsdbSongSource>>()));
|
|
builder.Services.AddSingleton<CSongSearch>();
|
|
builder.Services.AddSingleton<CDownloadQueue>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<CDownloadQueue>());
|
|
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();
|
|
// 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<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");
|
|
});
|
|
|
|
// 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 { }
|