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>
This commit is contained in:
1 parent
cf2fa796e2
commit
efcc63a640
29 files changed
+1753
-20
No files matched your search
@@ -0,0 +1,214 @@
|
||||
#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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user