Files
Vocaluxe/VocaluxeSongServer/Library/CLibraryScanner.cs
T
Simon DiesenreiterandClaude Opus 5.5 efcc63a640 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>
2026-11-05 17:23:00 +01:00

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);
}
}
}
}