Files
Vocaluxe/Tests/SongServer/CSongServerTest.cs
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

215 lines
10 KiB
C#

#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
extern alias songserver;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Vocaluxe.Base.SongServer;
using VocaluxeSongServer.Contracts;
using CLibraryScanner = songserver::VocaluxeSongServer.Library.CLibraryScanner;
using ServerProgram = songserver::Program;
namespace Tests.SongServer
{
[TestFixture]
public class CSongServerTest
{
private string _Root;
private string _Library;
private string _Cache;
[SetUp]
public void SetUp()
{
_Root = Path.Combine(Path.GetTempPath(), "vlx-songserver-test-" + Guid.NewGuid().ToString("N"));
_Library = Path.Combine(_Root, "library");
_Cache = Path.Combine(_Root, "cache");
Directory.CreateDirectory(Path.Combine(_Library, "A - One"));
Directory.CreateDirectory(Path.Combine(_Library, "Sub", "Künstler - Lied"));
Directory.CreateDirectory(Path.Combine(_Library, "NoSong"));
File.WriteAllText(Path.Combine(_Library, "A - One", "A - One.txt"), "#TITLE:One\n#ARTIST:A\n#MP3:One.mp3\n#COVER:cover.jpg\n#BPM:300\n: 0 4 5 La\nE\n");
File.WriteAllBytes(Path.Combine(_Library, "A - One", "One.mp3"), _RandomBytes(300000, 1));
File.WriteAllBytes(Path.Combine(_Library, "A - One", "cover.jpg"), _RandomBytes(2000, 2));
// Latin-1 encoded, like many old song files
File.WriteAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "lied.txt"),
Encoding.Latin1.GetBytes("#TITLE:Lied über\n#ARTIST:Künstler\n#AUDIO:a.ogg\n#BPM:200\n: 0 4 5 La\nE\n"));
File.WriteAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "a.ogg"), _RandomBytes(1000, 3));
File.WriteAllText(Path.Combine(_Library, "NoSong", "readme.txt"), "not a song");
}
[TearDown]
public void TearDown()
{
try
{
Directory.Delete(_Root, true);
}
catch (IOException) { }
}
private static byte[] _RandomBytes(int count, int seed)
{
var bytes = new byte[count];
new Random(seed).NextBytes(bytes);
return bytes;
}
[Test]
public void TestParseHeader()
{
var header = CLibraryScanner.ParseHeader("#TITLE:Song\r\n#ARTIST:Artist\r\n#MP3:x.mp3\r\n: 0 1 1 a\r\n#TITLE:ignored\n");
Assert.That(header, Is.Not.Null);
Assert.That(header.Title, Is.EqualTo("Song"));
Assert.That(header.Artist, Is.EqualTo("Artist"));
Assert.That(CLibraryScanner.ParseHeader("#TITLE:Song\n#ARTIST:Artist\n"), Is.Null, "no audio");
Assert.That(CLibraryScanner.ParseHeader("just text"), Is.Null);
}
[Test]
public void TestDecodeText()
{
Assert.That(CLibraryScanner.DecodeText(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("über")).ToArray()), Is.EqualTo("über"));
Assert.That(CLibraryScanner.DecodeText(Encoding.UTF8.GetBytes("über")), Is.EqualTo("über"));
Assert.That(CLibraryScanner.DecodeText(Encoding.Latin1.GetBytes("über")), Is.EqualTo("über"));
}
[Test]
public void TestScan()
{
var scanner = new CLibraryScanner(NullLogger<CLibraryScanner>.Instance);
var snapshot = scanner.Scan(_Library);
Assert.That(snapshot.Dto.Songs.Select(s => s.Path), Is.EqualTo(new[] { "A - One/A - One.txt", "Sub/Künstler - Lied/lied.txt" }));
Assert.That(snapshot.Dto.Songs[1].Title, Is.EqualTo("Lied über"));
Assert.That(snapshot.Dto.Files.Select(f => f.Path), Does.Not.Contain("NoSong/readme.txt"), "only files in song folders are served");
Assert.That(snapshot.Dto.Files.Count, Is.EqualTo(5));
var version = snapshot.Dto.Version;
Assert.That(scanner.Scan(_Library).Dto.Version, Is.EqualTo(version), "unchanged library, same version");
File.WriteAllBytes(Path.Combine(_Library, "A - One", "One.mp3"), _RandomBytes(300001, 1));
Assert.That(scanner.Scan(_Library).Dto.Version, Is.Not.EqualTo(version), "changed file, new version");
}
private WebApplicationFactory<ServerProgram> _CreateServer(string apiKey = "")
{
return new WebApplicationFactory<ServerProgram>().WithWebHostBuilder(b =>
{
b.UseSetting("Library:Path", _Library);
b.UseSetting("Library:ApiKey", apiKey);
});
}
private static async Task<LibraryDto> _GetLibrary(HttpClient client)
{
for (var i = 0; i < 50; i++)
{
var response = await client.GetAsync("/api/v1/library");
if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
{
response.EnsureSuccessStatusCode();
return JsonSerializer.Deserialize<LibraryDto>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
await Task.Delay(100); // first scan still running
}
throw new TimeoutException("library scan did not finish");
}
[Test]
public async Task TestApiKey()
{
using (var server = _CreateServer("secret"))
{
var client = server.CreateClient();
Assert.That((await client.GetAsync("/api/v1/library")).StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
client.DefaultRequestHeaders.Add("X-Api-Key", "secret");
Assert.That((await _GetLibrary(client)).Songs.Count, Is.EqualTo(2));
}
}
[Test]
public async Task TestDownloadOnDemand()
{
using (var server = _CreateServer())
{
var client = server.CreateClient();
var library = await _GetLibrary(client);
var provider = new CRemoteSongFileProvider(client, _Cache, library.Files);
var mp3 = CRemoteSongFileProvider.GetLocalPath(_Cache, "A - One/One.mp3");
// Case differs from the server file, as often in song files
var cover = CRemoteSongFileProvider.GetLocalPath(_Cache, "A - One/COVER.jpg");
var ogg = CRemoteSongFileProvider.GetLocalPath(_Cache, "Sub/Künstler - Lied/a.ogg");
Assert.That(provider.Exists(mp3), Is.True);
Assert.That(provider.IsLocal(mp3), Is.False);
Assert.That(provider.Exists(Path.Combine(_Cache, "nothing.mp3")), Is.False);
Assert.That(provider.GetProgress(new[] { mp3 }), Is.EqualTo(0f));
// Interrupted earlier download: the rest is fetched with a Range request
Directory.CreateDirectory(Path.GetDirectoryName(mp3));
File.WriteAllBytes(mp3 + ".part", _RandomBytes(300000, 1).Take(1000).ToArray());
await provider.RequestAsync(new[] { mp3, cover, ogg, mp3 });
Assert.That(provider.IsLocal(mp3) && provider.IsLocal(cover) && provider.IsLocal(ogg), Is.True);
Assert.That(File.ReadAllBytes(mp3), Is.EqualTo(File.ReadAllBytes(Path.Combine(_Library, "A - One", "One.mp3"))));
Assert.That(File.ReadAllBytes(ogg), Is.EqualTo(File.ReadAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "a.ogg"))));
Assert.That(File.Exists(mp3 + ".part"), Is.False);
Assert.That(provider.GetProgress(new[] { mp3, cover, ogg }), Is.EqualTo(1f));
// A changed file on the server makes the local copy outdated
var changed = library.Files.Select(f => f.Path == "A - One/One.mp3" ? new FileDto { Path = f.Path, Size = f.Size, Modified = f.Modified + 60000 } : f);
Assert.That(new CRemoteSongFileProvider(client, _Cache, changed).IsLocal(mp3), Is.False);
}
}
[Test]
public async Task TestBundleAndFileSecurity()
{
using (var server = _CreateServer())
{
var client = server.CreateClient();
await _GetLibrary(client);
var body = new StringContent(JsonSerializer.Serialize(new BundleRequest { Paths = new List<string> { "Sub/Künstler - Lied/lied.txt", "NoSong/readme.txt" } },
new JsonSerializerOptions(JsonSerializerDefaults.Web)), Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/files/bundle", body);
response.EnsureSuccessStatusCode();
using (var zip = new System.IO.Compression.ZipArchive(await response.Content.ReadAsStreamAsync()))
{
Assert.That(zip.Entries.Select(e => e.FullName), Is.EqualTo(new[] { "Sub/Künstler - Lied/lied.txt" }));
}
Assert.That((await client.GetAsync("/api/v1/files/NoSong/readme.txt")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That((await client.GetAsync("/api/v1/files/..%2F..%2Fsecret.txt")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
}
}
}