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>
This commit is contained in:
Simon DiesenreiterandClaude Opus 5.5 committed 2026-11-05 17:23:00 +01:00
1 parent cf2fa796e2
commit efcc63a640
29 files changed
+1753 -20

No files matched your search

@@ -0,0 +1,58 @@
#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System.Collections.Generic;
// Shared between the song server and the game (linked into Vocaluxe.csproj). Keep it free of dependencies.
// Serialized as camelCase JSON. All paths are relative to the library root and use '/'.
namespace VocaluxeSongServer.Contracts
{
/// <summary>GET /api/v1/library</summary>
public sealed class LibraryDto
{
/// <summary>Changes whenever any song file is added, removed or changed. Also sent as ETag.</summary>
public string Version { get; set; } = "";
public string ScannedAt { get; set; } = "";
public List<SongDto> Songs { get; set; } = new List<SongDto>();
/// <summary>All files in the song folders (text files, audio, video, images), downloadable via /api/v1/files/{path}.</summary>
public List<FileDto> Files { get; set; } = new List<FileDto>();
}
public sealed class SongDto
{
/// <summary>Stable id, derived from the path of the text file.</summary>
public string Id { get; set; } = "";
/// <summary>Path of the UltraStar text file.</summary>
public string Path { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
}
public sealed class FileDto
{
public string Path { get; set; } = "";
public long Size { get; set; }
/// <summary>Last modification, Unix time in milliseconds (UTC).</summary>
public long Modified { get; set; }
}
/// <summary>POST /api/v1/files/bundle: returns the files as a zip archive (entries named by their path).</summary>
public sealed class BundleRequest
{
public List<string> Paths { get; set; } = new List<string>();
}
}
+18
View File
@@ -0,0 +1,18 @@
# Build from the repository root: docker build -f VocaluxeSongServer/Dockerfile -t vocaluxe-song-server .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Directory.Build.props NuGet.Config ./
COPY VocaluxeSongServer/VocaluxeSongServer.csproj VocaluxeSongServer/
RUN dotnet restore VocaluxeSongServer/VocaluxeSongServer.csproj
COPY VocaluxeSongServer/ VocaluxeSongServer/
RUN dotnet publish VocaluxeSongServer/VocaluxeSongServer.csproj -c ReleaseLinux --no-restore -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# The song library (read-only is enough); see README.md
VOLUME /songs
ENV Library__Path=/songs
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "VocaluxeSongServer.dll"]
+106
View File
@@ -0,0 +1,106 @@
using Microsoft.Extensions.Options;
namespace VocaluxeSongServer.Library
{
public sealed class CLibraryOptions
{
/// <summary>Root folder of the song library (in Docker: the mounted share, default /songs).</summary>
public string Path { get; set; } = "/songs";
/// <summary>Minutes between automatic rescans. 0 = only at startup and via POST /api/v1/library/rescan.</summary>
public int ScanIntervalMinutes { get; set; } = 10;
/// <summary>If set, every /api request needs the header X-Api-Key with this value.</summary>
public string ApiKey { get; set; } = "";
}
/// <summary>Holds the current library snapshot and rescans it in the background.</summary>
public sealed class CLibraryIndex : BackgroundService
{
private readonly CLibraryScanner _Scanner;
private readonly CLibraryOptions _Options;
private readonly ILogger<CLibraryIndex> _Logger;
private readonly SemaphoreSlim _ScanLock = new(1, 1);
private readonly SemaphoreSlim _RescanRequested = new(0, 1);
private volatile CLibrarySnapshot? _Current;
public CLibraryIndex(CLibraryScanner scanner, IOptions<CLibraryOptions> options, ILogger<CLibraryIndex> logger)
{
_Scanner = scanner;
_Options = options.Value;
_Logger = logger;
}
/// <summary>The last completed scan, or null while the first scan is running.</summary>
public CLibrarySnapshot? Current => _Current;
public string Root => System.IO.Path.GetFullPath(_Options.Path);
/// <summary>Asks for a rescan as soon as possible (returns immediately).</summary>
public void RequestRescan()
{
if (_RescanRequested.CurrentCount == 0)
{
try
{
_RescanRequested.Release();
}
catch (SemaphoreFullException)
{
// Already requested.
}
}
}
public async Task ScanNowAsync(CancellationToken ct = default)
{
await _ScanLock.WaitAsync(ct);
try
{
if (!Directory.Exists(Root))
{
_Logger.LogError("Library folder {Root} does not exist", Root);
return;
}
var started = DateTime.UtcNow;
var snapshot = await Task.Run(() => _Scanner.Scan(Root), ct);
var changed = _Current?.Dto.Version != snapshot.Dto.Version;
_Current = snapshot;
_Logger.LogInformation("Scanned {Root}: {Songs} songs, {Files} files in {Seconds:0.0} s{Changed}", Root,
snapshot.Dto.Songs.Count, snapshot.Dto.Files.Count, (DateTime.UtcNow - started).TotalSeconds, changed ? "" : " (unchanged)");
}
finally
{
_ScanLock.Release();
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ScanNowAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception e)
{
_Logger.LogError(e, "Library scan failed");
}
var interval = _Options.ScanIntervalMinutes > 0 ? TimeSpan.FromMinutes(_Options.ScanIntervalMinutes) : Timeout.InfiniteTimeSpan;
try
{
await _RescanRequested.WaitAsync(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
}
@@ -0,0 +1,232 @@
using System.Security.Cryptography;
using System.Text;
using VocaluxeSongServer.Contracts;
namespace VocaluxeSongServer.Library
{
/// <summary>A scanned library: the DTO sent to clients plus the lookup of downloadable files.</summary>
public sealed class CLibrarySnapshot
{
public required LibraryDto Dto { get; init; }
/// <summary>Relative path ('/') -> full path, case-sensitive like the file system on the server.</summary>
public required IReadOnlyDictionary<string, string> FullPaths { get; init; }
}
/// <summary>
/// Finds UltraStar songs (text files with #TITLE, #ARTIST and #MP3/#AUDIO) below the library root.
/// Only the header of each text file is read; unchanged files are not read again on the next scan.
/// </summary>
public sealed class CLibraryScanner
{
private sealed record CTxtInfo(long Size, long Modified, CSongHeader? Header);
public sealed record CSongHeader(string Title, string Artist);
private readonly ILogger<CLibraryScanner> _Logger;
private Dictionary<string, CTxtInfo> _TxtCache = new(StringComparer.Ordinal);
public CLibraryScanner(ILogger<CLibraryScanner> logger)
{
_Logger = logger;
}
private static readonly EnumerationOptions _Recursive = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MatchCasing = MatchCasing.CaseInsensitive,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System
};
private static readonly EnumerationOptions _TopLevel = new()
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.Directory
};
public CLibrarySnapshot Scan(string root)
{
root = Path.GetFullPath(root);
var songs = new List<SongDto>();
var songFolders = new HashSet<string>(StringComparer.Ordinal);
var newCache = new Dictionary<string, CTxtInfo>(StringComparer.Ordinal);
foreach (var txt in Directory.EnumerateFiles(root, "*.txt", _Recursive))
{
FileInfo fi;
try
{
fi = new FileInfo(txt);
}
catch (Exception e)
{
_Logger.LogWarning("Can't read {File}: {Message}", txt, e.Message);
continue;
}
var modified = new DateTimeOffset(fi.LastWriteTimeUtc).ToUnixTimeMilliseconds();
if (!_TxtCache.TryGetValue(txt, out var info) || info.Size != fi.Length || info.Modified != modified)
{
info = new CTxtInfo(fi.Length, modified, ReadHeader(txt));
}
newCache[txt] = info;
if (info.Header == null)
{
continue;
}
var rel = ToRelative(root, txt);
songs.Add(new SongDto { Id = SongId(rel), Path = rel, Title = info.Header.Title, Artist = info.Header.Artist });
songFolders.Add(fi.DirectoryName!);
}
_TxtCache = newCache;
var files = new List<FileDto>();
var fullPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var folder in songFolders)
{
foreach (var file in Directory.EnumerateFiles(folder, "*", _TopLevel))
{
var fi = new FileInfo(file);
var rel = ToRelative(root, file);
files.Add(new FileDto { Path = rel, Size = fi.Length, Modified = new DateTimeOffset(fi.LastWriteTimeUtc).ToUnixTimeMilliseconds() });
fullPaths[rel] = file;
}
}
songs.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
files.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
var dto = new LibraryDto
{
Version = ComputeVersion(files),
ScannedAt = DateTimeOffset.UtcNow.ToString("O"),
Songs = songs,
Files = files
};
return new CLibrarySnapshot { Dto = dto, FullPaths = fullPaths };
}
public static string ToRelative(string root, string path)
{
return Path.GetRelativePath(root, path).Replace('\\', '/');
}
public static string SongId(string relativePath)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(relativePath));
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
private static string ComputeVersion(List<FileDto> files)
{
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var f in files)
{
sha.AppendData(Encoding.UTF8.GetBytes(f.Path + "\n" + f.Size + "\n" + f.Modified + "\n"));
}
return Convert.ToHexString(sha.GetHashAndReset(), 0, 12).ToLowerInvariant();
}
/// <summary>
/// Reads the header of an UltraStar text file. Returns null if it is not a song
/// (no #TITLE, #ARTIST or #MP3/#AUDIO tag before the first note line).
/// </summary>
public CSongHeader? ReadHeader(string path)
{
try
{
// The header is at the top; 64 KB is plenty even with long comments.
byte[] bytes;
using (var fs = File.OpenRead(path))
{
var len = (int)Math.Min(fs.Length, 64 * 1024);
bytes = new byte[len];
fs.ReadExactly(bytes, 0, len);
if (fs.Length > len)
{
// Cut at the last line break so a UTF-8 character isn't split (which would look like Latin-1).
var lastBreak = Array.LastIndexOf(bytes, (byte)'\n');
if (lastBreak > 0)
{
Array.Resize(ref bytes, lastBreak);
}
}
}
return ParseHeader(DecodeText(bytes));
}
catch (Exception e)
{
_Logger.LogWarning("Can't read {File}: {Message}", path, e.Message);
return null;
}
}
public static CSongHeader? ParseHeader(string text)
{
string? title = null, artist = null;
var hasAudio = false;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line[0] != '#')
{
break;
}
var colon = line.IndexOf(':');
if (colon < 0)
{
continue;
}
var tag = line.Substring(1, colon - 1).Trim().ToUpperInvariant();
var value = line.Substring(colon + 1).Trim();
switch (tag)
{
case "TITLE":
title = value;
break;
case "ARTIST":
artist = value;
break;
case "MP3":
case "AUDIO":
hasAudio |= value.Length > 0;
break;
}
}
return !string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist) && hasAudio ? new CSongHeader(title, artist) : null;
}
/// <summary>UTF-8 (with or without BOM) if valid, otherwise Windows-1252/Latin-1 like most old song files.</summary>
public static string DecodeText(byte[] bytes)
{
var span = bytes.AsSpan();
if (span.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }))
{
span = span[3..];
}
try
{
return new UTF8Encoding(false, true).GetString(span);
}
catch (DecoderFallbackException)
{
return Encoding.Latin1.GetString(span);
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
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 { }
+63
View File
@@ -0,0 +1,63 @@
# Vocaluxe song server
A central song library for Vocaluxe. The server hosts the UltraStar song folders (text file, audio, video, cover,
backgrounds). Vocaluxe mirrors the song text files at startup and downloads audio, video and images only when they are
needed, then keeps them in a local cache.
## Running it (Docker)
```
docker run -d --name vocaluxe-song-server -p 8099:8080 \
-v /mnt/user/karaoke/songs:/songs:ro \
git.disi.dev/projects/vocaluxe-song-server:latest
```
| Setting (environment variable) | Default | Meaning |
|---------------------------------|----------|----------------------------------------------------------------------|
| `Library__Path` | `/songs` | Root folder of the song library; any folder structure below it works |
| `Library__ScanIntervalMinutes` | `10` | Rescan interval; `0` = only at startup and via the rescan endpoint |
| `Library__ApiKey` | empty | If set, clients must send it as `X-Api-Key` |
A song is any `.txt` file with `#TITLE`, `#ARTIST` and `#MP3` (or `#AUDIO`). All files in its folder are served.
Files outside song folders are never served.
## Using it in Vocaluxe
In `config.xml`:
```xml
<SongServer>
<Url>http://192.168.1.108:8099</Url>
<ApiKey />
</SongServer>
```
Local song folders keep working alongside the server. The cache is in the Vocaluxe data folder under
`SongServer/<host>_<port>`; it mirrors the server, so songs removed on the server disappear from it. Without a
connection Vocaluxe uses the library of the last start, and every song whose files were downloaded before can be sung.
What is downloaded when:
* **Startup:** the song list and all song text files (only changed ones after the first time). Covers are downloaded by
the cover loader in the background.
* **Song selected (preview):** the audio file; the preview starts when it's there. The video is downloaded in the
background.
* **Song started:** everything still missing (video, instrumental/vocals, backgrounds). The sing screen shows the
progress and starts the song when the download is finished.
## API
All endpoints are under `/api/v1` (plus `GET /health`). Paths are relative to the library root and use `/`.
The types are in `Contracts/LibraryDtos.cs`, which Vocaluxe links in.
| Endpoint | What it does |
|------------------------------|-------------------------------------------------------------------------------------|
| `GET /library` | Songs (id, path, title, artist) and all files (path, size, modified); ETag/304 |
| `GET /files/{path}` | One file, with Range support (resumable downloads) |
| `POST /files/bundle` | Several files as a zip, body `{"paths": [...]}` (max. 2000), used for text files |
| `POST /library/rescan` | Rescan now instead of waiting for the interval |
## Building and releasing
`dotnet run --project VocaluxeSongServer -- --Library:Path=D:\Songs --urls http://localhost:8099` runs it locally.
The image is built by `.gitea/workflows/song-server.yml`: set `VERSION`, then push the tag `songserver-v<version>`
(pushes `:<version>` and `:latest`). Tests: `Tests/SongServer`.
+1
View File
@@ -0,0 +1 @@
0.1.0
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- The song server: hosts the song library (UltraStar folders) for Vocaluxe clients. Runs in Docker on Linux,
see README.md. Target framework and configurations come from ../Directory.Build.props. -->
<PropertyGroup>
<RootNamespace>VocaluxeSongServer</RootNamespace>
<AssemblyName>VocaluxeSongServer</AssemblyName>
<Platforms>AnyCPU</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<CodeAnalysisRuleSet></CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Tests" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Urls": "http://0.0.0.0:8080",
"Library": {
"Path": "/songs",
"ScanIntervalMinutes": 10,
"ApiKey": ""
}
}