- 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>
215 lines
10 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|
|
}
|