- 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>
233 lines
8.4 KiB
C#
233 lines
8.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|