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:
1 parent
cf2fa796e2
commit
efcc63a640
29 files changed
+1753
-20
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user