using System.Security.Cryptography; using System.Text; using VocaluxeSongServer.Contracts; namespace VocaluxeSongServer.Library { /// A scanned library: the DTO sent to clients plus the lookup of downloadable files. public sealed class CLibrarySnapshot { public required LibraryDto Dto { get; init; } /// Relative path ('/') -> full path, case-sensitive like the file system on the server. public required IReadOnlyDictionary FullPaths { get; init; } } /// /// 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. /// 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 _Logger; private Dictionary _TxtCache = new(StringComparer.Ordinal); public CLibraryScanner(ILogger 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(); var songFolders = new HashSet(StringComparer.Ordinal); var newCache = new Dictionary(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(); var fullPaths = new Dictionary(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 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(); } /// /// 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). /// 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; } /// UTF-8 (with or without BOM) if valid, otherwise Windows-1252/Latin-1 like most old song files. 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); } } } }