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,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