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,47 @@
|
|||||||
|
# Builds the song server image and pushes it to the Gitea registry:
|
||||||
|
# git.disi.dev/projects/vocaluxe-song-server:<version> and :latest
|
||||||
|
# Release: set VocaluxeSongServer/VERSION, then push the tag songserver-v<version> (e.g. songserver-v0.1.0).
|
||||||
|
# Manual runs (workflow_dispatch) push :dev only.
|
||||||
|
name: Song server image
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_API_VERSION: '1.43'
|
||||||
|
IMAGE: git.disi.dev/projects/vocaluxe-song-server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'songserver-v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- name: Determine tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION=$(tr -d '[:space:]' < VocaluxeSongServer/VERSION)
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
if [ "${GITHUB_REF_NAME}" != "songserver-v${VERSION}" ]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match VocaluxeSongServer/VERSION (${VERSION})"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "tags=${VERSION} latest" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tags=dev" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Login to Gitea container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: gitearobot
|
||||||
|
password: ${{ secrets.PACKAGE_GITEA_PAT }}
|
||||||
|
registry: git.disi.dev
|
||||||
|
- name: Build and publish
|
||||||
|
run: |
|
||||||
|
docker build -t "${IMAGE}:build" -f VocaluxeSongServer/Dockerfile .
|
||||||
|
for TAG in ${{ steps.tags.outputs.tags }}; do
|
||||||
|
docker tag "${IMAGE}:build" "${IMAGE}:${TAG}"
|
||||||
|
docker push "${IMAGE}:${TAG}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Vocaluxe (fork at git.disi.dev/Projects/Vocaluxe)
|
||||||
|
|
||||||
|
Fork of [Vocaluxe/Vocaluxe](https://github.com/Vocaluxe/Vocaluxe), a karaoke game (C#, .NET 10). Default branch
|
||||||
|
`develop` tracks upstream; the fork's own work happens on feature branches. Keep changes to upstream code small and
|
||||||
|
hook-based, so upstream can still be merged.
|
||||||
|
|
||||||
|
## Goal of the fork
|
||||||
|
1. **Song server** (`VocaluxeSongServer/`, in progress): a central song library on the homelab. The game becomes the
|
||||||
|
frontend and downloads song files on demand. See `VocaluxeSongServer/README.md`.
|
||||||
|
2. Later: serve the guest WebApp ("browse by phone", `WebApp/` + `VocaluxeWeb/`) directly from the server.
|
||||||
|
3. Later: search and download songs that aren't in the library, from common internet sources (possibly UltraSinger
|
||||||
|
to create songs from audio).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
| Folder | What |
|
||||||
|
|---|---|
|
||||||
|
| `Vocaluxe/` | The game. Song server client: `Base/SongServer/` (`CSongServer` sync, `CRemoteSongFileProvider` downloads) |
|
||||||
|
| `VocaluxeLib/` | Shared library. `Songs/CSongFiles.cs` is the hook every song file access goes through |
|
||||||
|
| `VocaluxeSongServer/` | ASP.NET Core server, Docker image `git.disi.dev/projects/vocaluxe-song-server` |
|
||||||
|
| `VocaluxeWeb/`, `WebApp/` | Built-in webserver of the game and the guest WebApp (Vue), upstream |
|
||||||
|
| `Tests/` | NUnit; song server tests in `Tests/SongServer` |
|
||||||
|
|
||||||
|
## How the song server integration works
|
||||||
|
- `CSongs.LoadSongs` calls `CSongServer.Sync()`: fetches `/api/v1/library` (ETag), mirrors the song `.txt` files into
|
||||||
|
`<DataFolder>/SongServer/<host>_<port>` (zip bundles), deletes files no longer on the server, and installs
|
||||||
|
`CRemoteSongFileProvider` as `CSongFiles.Provider`. The cache folder is then loaded like a normal song folder.
|
||||||
|
- `CSongFiles.Exists` (loader header checks) is true for files that only exist on the server. `IsLocal` means the file
|
||||||
|
is downloaded and current (size and mtime match the server).
|
||||||
|
- Downloads on demand: covers (`CSong.LoadSmallCover`, background threads, blocking), preview audio
|
||||||
|
(`CBackgroundMusic.LoadPreview` + `Update`), everything before singing (`CScreenSing._LoadAndStartCurrentSong`,
|
||||||
|
progress via `TR_SCREENSING_DOWNLOADING`). Downloads resume from `.part` files with Range requests.
|
||||||
|
- Config: `config.xml` section `SongServer` (`Url`, `ApiKey`). The contracts (`VocaluxeSongServer/Contracts`) are
|
||||||
|
linked into `Vocaluxe.csproj`; the test project references the server with `extern alias songserver`.
|
||||||
|
|
||||||
|
## Build and test (Windows)
|
||||||
|
- `dotnet build Vocaluxe/Vocaluxe.csproj -c DebugWin -p:Platform=x64` (the whole solution also builds the native
|
||||||
|
`PitchTracker`, which needs Visual Studio's C++ tools).
|
||||||
|
- `dotnet test Tests/Tests.csproj -c DebugWin -p:Platform=x64`
|
||||||
|
- Building rewrites the copyright year in `Vocaluxe/Properties/AssemblyInfo.cs`; don't commit that.
|
||||||
|
- Don't use `dotnet sln add`: it rewrites the configuration table of `Vocaluxe.sln`. Copy the lines of `VocaluxeWeb`.
|
||||||
|
- New `config.xml` fields must also go into `Tests/VocaluxeLib/XML/TestFiles/SConfig.xml` (round-trip test; CRLF).
|
||||||
|
|
||||||
|
## Known gaps (song server)
|
||||||
|
- Cover/background auto-detection (`[CO]`/`[BG]` files without a tag, `CSong._CheckFiles`) only sees local files.
|
||||||
|
- Media in subfolders of a song folder are not served (only the song folder itself).
|
||||||
|
- On Linux clients, media whose name differs in case from the `.txt` reference won't be found after download.
|
||||||
|
- No cache size limit yet. The WebApp's cover images for server songs depend on the cover loader having fetched them.
|
||||||
|
- No in-game UI for the server address yet (only `config.xml`).
|
||||||
@@ -267,6 +267,7 @@
|
|||||||
<string name="TR_SCREENSING_BUTTON_RESTART_GAME">Restart game</string>
|
<string name="TR_SCREENSING_BUTTON_RESTART_GAME">Restart game</string>
|
||||||
<string name="TR_SCREENSING_VOCALSVOLUME">Vocals track volume</string>
|
<string name="TR_SCREENSING_VOCALSVOLUME">Vocals track volume</string>
|
||||||
<string name="TR_SCREENSING_NOMORESONGS">No more songs in playlist!</string>
|
<string name="TR_SCREENSING_NOMORESONGS">No more songs in playlist!</string>
|
||||||
|
<string name="TR_SCREENSING_DOWNLOADING">Downloading %s ... %p%</string>
|
||||||
<string name="TR_SCREENSING_NOWEBCAM">No webcam available!</string>
|
<string name="TR_SCREENSING_NOWEBCAM">No webcam available!</string>
|
||||||
<string name="TR_SCREENSING_ASPECTRATIO">Aspect Ratio: %a</string>
|
<string name="TR_SCREENSING_ASPECTRATIO">Aspect Ratio: %a</string>
|
||||||
<string name="TR_SCREENSING_RATING_1">perfect!</string>
|
<string name="TR_SCREENSING_RATING_1">perfect!</string>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" PrivateAssets="all" />
|
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="NunitXml.TestLogger" Version="8.1.0" />
|
<PackageReference Include="NunitXml.TestLogger" Version="8.1.0" />
|
||||||
<PackageReference Include="coverlet.collector" Version="10.1.0" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" Version="10.1.0" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Update="VocaluxeLib\Log\CLogTest.cs">
|
<Compile Update="VocaluxeLib\Log\CLogTest.cs">
|
||||||
@@ -32,5 +33,7 @@
|
|||||||
<ProjectReference Include="..\VocaluxeLib\VocaluxeLib.csproj" SetPlatform="Platform=AnyCPU" />
|
<ProjectReference Include="..\VocaluxeLib\VocaluxeLib.csproj" SetPlatform="Platform=AnyCPU" />
|
||||||
<ProjectReference Include="..\Vocaluxe\Vocaluxe.csproj" />
|
<ProjectReference Include="..\Vocaluxe\Vocaluxe.csproj" />
|
||||||
<ProjectReference Include="..\VocaluxeWeb\VocaluxeWeb.csproj" SetPlatform="Platform=AnyCPU" />
|
<ProjectReference Include="..\VocaluxeWeb\VocaluxeWeb.csproj" SetPlatform="Platform=AnyCPU" />
|
||||||
|
<!-- Alias: the contract types also exist in Vocaluxe (linked source) -->
|
||||||
|
<ProjectReference Include="..\VocaluxeSongServer\VocaluxeSongServer.csproj" SetPlatform="Platform=AnyCPU" Aliases="songserver" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -137,4 +137,8 @@
|
|||||||
<ServerPort>3000</ServerPort>
|
<ServerPort>3000</ServerPort>
|
||||||
<SongCountCoverThreshold>70</SongCountCoverThreshold>
|
<SongCountCoverThreshold>70</SongCountCoverThreshold>
|
||||||
</Server>
|
</Server>
|
||||||
|
<SongServer>
|
||||||
|
<Url />
|
||||||
|
<ApiKey />
|
||||||
|
</SongServer>
|
||||||
</root>
|
</root>
|
||||||
@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeTournament", "Part
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeWeb", "VocaluxeWeb\VocaluxeWeb.csproj", "{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeWeb", "VocaluxeWeb\VocaluxeWeb.csproj", "{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeSongServer", "VocaluxeSongServer\VocaluxeSongServer.csproj", "{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeRuleRoulette", "PartyModes\PartyModeRuleRoulette\PartyModeRuleRoulette.csproj", "{2E6A9B48-71C3-4D05-8F2B-C4A7D19E6E3F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeRuleRoulette", "PartyModes\PartyModeRuleRoulette\PartyModeRuleRoulette.csproj", "{2E6A9B48-71C3-4D05-8F2B-C4A7D19E6E3F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PitchTracker", "PitchTracker\PitchTracker.vcxproj", "{54A48AF7-515C-4EDD-B395-2BFFA096050A}"
|
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PitchTracker", "PitchTracker\PitchTracker.vcxproj", "{54A48AF7-515C-4EDD-B395-2BFFA096050A}"
|
||||||
@@ -264,6 +266,22 @@ Global
|
|||||||
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x64.Build.0 = ReleaseWin|Any CPU
|
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x64.Build.0 = ReleaseWin|Any CPU
|
||||||
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.ActiveCfg = ReleaseWin|Any CPU
|
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.ActiveCfg = ReleaseWin|Any CPU
|
||||||
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.Build.0 = ReleaseWin|Any CPU
|
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.Build.0 = ReleaseWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugLinux|x64.ActiveCfg = DebugLinux|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugLinux|x86.ActiveCfg = DebugLinux|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x64.ActiveCfg = DebugWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x64.Build.0 = DebugWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x86.ActiveCfg = DebugWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x86.Build.0 = DebugWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x64.ActiveCfg = InstallerWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x64.Build.0 = InstallerWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x86.ActiveCfg = InstallerWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x86.Build.0 = InstallerWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseLinux|x64.ActiveCfg = ReleaseLinux|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseLinux|x86.ActiveCfg = ReleaseLinux|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x64.ActiveCfg = ReleaseWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x64.Build.0 = ReleaseWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x86.ActiveCfg = ReleaseWin|Any CPU
|
||||||
|
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x86.Build.0 = ReleaseWin|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using VocaluxeLib;
|
using VocaluxeLib;
|
||||||
using VocaluxeLib.Draw;
|
using VocaluxeLib.Draw;
|
||||||
@@ -55,6 +56,11 @@ namespace Vocaluxe.Base
|
|||||||
private const int _PreviewStartWaitMaxTries = 10;
|
private const int _PreviewStartWaitMaxTries = 10;
|
||||||
private static readonly object _PreviewStartHelperTaskLock = new Object();
|
private static readonly object _PreviewStartHelperTaskLock = new Object();
|
||||||
|
|
||||||
|
// Song server: preview waiting for its audio file to be downloaded (main thread only)
|
||||||
|
private static CSong _PendingPreviewSong;
|
||||||
|
private static float _PendingPreviewStart;
|
||||||
|
private static Task _PendingPreviewTask;
|
||||||
|
|
||||||
public static bool VideoEnabled
|
public static bool VideoEnabled
|
||||||
{
|
{
|
||||||
get { return _CurPlayer.VideoEnabled; }
|
get { return _CurPlayer.VideoEnabled; }
|
||||||
@@ -309,6 +315,16 @@ namespace Vocaluxe.Base
|
|||||||
|
|
||||||
public static void Update()
|
public static void Update()
|
||||||
{
|
{
|
||||||
|
if (_PendingPreviewSong != null && _PendingPreviewTask.IsCompleted)
|
||||||
|
{
|
||||||
|
var song = _PendingPreviewSong;
|
||||||
|
_PendingPreviewSong = null;
|
||||||
|
if (CSongFiles.IsLocal(song.GetMP3()))
|
||||||
|
{
|
||||||
|
LoadPreview(song, _PendingPreviewStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (_PreviewStartHelperTask != null && (_PreviewPlayer.Length > 0 || _PreviewStartWaitCounter++ >= _PreviewStartWaitMaxTries))
|
if (_PreviewStartHelperTask != null && (_PreviewPlayer.Length > 0 || _PreviewStartWaitCounter++ >= _PreviewStartWaitMaxTries))
|
||||||
{
|
{
|
||||||
lock (_PreviewStartHelperTaskLock)
|
lock (_PreviewStartHelperTaskLock)
|
||||||
@@ -419,6 +435,27 @@ namespace Vocaluxe.Base
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Song server: download the audio first, the preview starts in Update() when it's there.
|
||||||
|
// The video is downloaded in the background (the preview shows it from the next time on).
|
||||||
|
if (!string.IsNullOrEmpty(song.Video))
|
||||||
|
{
|
||||||
|
CSongFiles.RequestAsync(new[] { song.GetVideo() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CSongFiles.IsLocal(song.GetMP3()))
|
||||||
|
{
|
||||||
|
if (IsPlayingPreview)
|
||||||
|
{
|
||||||
|
_PreviewPlayer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
_PendingPreviewSong = song;
|
||||||
|
_PendingPreviewStart = start;
|
||||||
|
_PendingPreviewTask = CSongFiles.RequestAsync(new[] { song.GetMP3() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_PendingPreviewSong = null;
|
||||||
_PreviewPlayer.Load(song);
|
_PreviewPlayer.Load(song);
|
||||||
|
|
||||||
lock (_PreviewStartHelperTaskLock)
|
lock (_PreviewStartHelperTaskLock)
|
||||||
@@ -456,6 +493,7 @@ namespace Vocaluxe.Base
|
|||||||
|
|
||||||
public static void StopPreview()
|
public static void StopPreview()
|
||||||
{
|
{
|
||||||
|
_PendingPreviewSong = null;
|
||||||
if (!IsPlayingPreview)
|
if (!IsPlayingPreview)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -520,7 +558,8 @@ namespace Vocaluxe.Base
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var song in CSongs.AllSongs)
|
// Songs from the song server only when they are downloaded already
|
||||||
|
foreach (var song in CSongs.AllSongs.Where(s => CSongFiles.IsLocal(s.GetMP3())))
|
||||||
{
|
{
|
||||||
_NotPlayedFiles.Add(new CPlaylistElement(song));
|
_NotPlayedFiles.Add(new CPlaylistElement(song));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ namespace Vocaluxe.Base
|
|||||||
[DefaultValue(70)]
|
[DefaultValue(70)]
|
||||||
public int SongCountCoverThreshold;
|
public int SongCountCoverThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SConfigSongServer
|
||||||
|
{
|
||||||
|
[DefaultValue("")]
|
||||||
|
public string Url;
|
||||||
|
[DefaultValue("")]
|
||||||
|
public string ApiKey;
|
||||||
|
}
|
||||||
#pragma warning restore 649
|
#pragma warning restore 649
|
||||||
// ReSharper restore UnassignedField.Global
|
// ReSharper restore UnassignedField.Global
|
||||||
|
|
||||||
@@ -265,6 +273,7 @@ namespace Vocaluxe.Base
|
|||||||
public SConfigVideo Video;
|
public SConfigVideo Video;
|
||||||
public SConfigRecord Record;
|
public SConfigRecord Record;
|
||||||
public SConfigServer Server;
|
public SConfigServer Server;
|
||||||
|
public SConfigSongServer SongServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static SConfig Config;
|
public static SConfig Config;
|
||||||
@@ -643,6 +652,10 @@ namespace Vocaluxe.Base
|
|||||||
case "SongCountCoverThreshold":
|
case "SongCountCoverThreshold":
|
||||||
return
|
return
|
||||||
"Threshold of songs for that covers will not longer be included in get-all-songs-requests (e.g. song list) (default: 70) [-1..65535] -1 => always deliver covers";
|
"Threshold of songs for that covers will not longer be included in get-all-songs-requests (e.g. song list) (default: 70) [-1..65535] -1 => always deliver covers";
|
||||||
|
case "Url":
|
||||||
|
return "Song server (VocaluxeSongServer) address, e.g. http://192.168.1.108:8099. Empty = no song server, only local song folders";
|
||||||
|
case "ApiKey":
|
||||||
|
return "API key of the song server, if it requires one";
|
||||||
case "Stretch":
|
case "Stretch":
|
||||||
return "Stretch view to full window size: " + CHelper.ListStrings(Enum.GetNames(typeof(EOffOn)));
|
return "Stretch view to full window size: " + CHelper.ListStrings(Enum.GetNames(typeof(EOffOn)));
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
using Vocaluxe.Base.Fonts;
|
using Vocaluxe.Base.Fonts;
|
||||||
using Vocaluxe.Base.ThemeSystem;
|
using Vocaluxe.Base.ThemeSystem;
|
||||||
using VocaluxeLib;
|
using VocaluxeLib;
|
||||||
@@ -133,7 +134,7 @@ namespace Vocaluxe.Base
|
|||||||
|
|
||||||
public IEnumerable<string> GetSongFolders()
|
public IEnumerable<string> GetSongFolders()
|
||||||
{
|
{
|
||||||
return CConfig.SongFolders;
|
return CConfig.SongFolders.Concat(SongServer.CSongServer.SongFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ESongSorting GetSongSorting()
|
public ESongSorting GetSongSorting()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Vocaluxe.Base.SongServer;
|
||||||
using VocaluxeLib;
|
using VocaluxeLib;
|
||||||
using VocaluxeLib.Log;
|
using VocaluxeLib.Log;
|
||||||
using VocaluxeLib.Songs;
|
using VocaluxeLib.Songs;
|
||||||
@@ -418,11 +419,14 @@ namespace Vocaluxe.Base
|
|||||||
|
|
||||||
IEnumerable<string> files;
|
IEnumerable<string> files;
|
||||||
|
|
||||||
|
// Song server: mirror the song text files into the cache folder, which is then loaded like a song folder.
|
||||||
|
CSongServer.Sync();
|
||||||
|
|
||||||
using (CBenchmark.Time("List Songs"))
|
using (CBenchmark.Time("List Songs"))
|
||||||
{
|
{
|
||||||
var fileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var fileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var path in CConfig.SongFolders)
|
foreach (var path in CConfig.SongFolders.Concat(CSongServer.SongFolders))
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
#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
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using VocaluxeLib.Log;
|
||||||
|
using VocaluxeLib.Songs;
|
||||||
|
using VocaluxeSongServer.Contracts;
|
||||||
|
|
||||||
|
namespace Vocaluxe.Base.SongServer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Song files of the song server: the cache folder mirrors the server library, files are downloaded on demand.
|
||||||
|
/// Files outside the cache folder (local song folders) are passed through to the file system.
|
||||||
|
/// </summary>
|
||||||
|
class CRemoteSongFileProvider : ISongFileProvider
|
||||||
|
{
|
||||||
|
private const int _MaxParallelDownloads = 4;
|
||||||
|
|
||||||
|
private readonly HttpClient _Http;
|
||||||
|
private readonly string _CacheFolder;
|
||||||
|
// Full local path -> server file. Case-insensitive: song files often reference media with different case.
|
||||||
|
private readonly Dictionary<string, FileDto> _Files;
|
||||||
|
private readonly ConcurrentDictionary<string, Lazy<Task>> _Downloads = new ConcurrentDictionary<string, Lazy<Task>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ConcurrentDictionary<string, long> _BytesDone = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly SemaphoreSlim _Parallel = new SemaphoreSlim(_MaxParallelDownloads);
|
||||||
|
|
||||||
|
public CRemoteSongFileProvider(HttpClient http, string cacheFolder, IEnumerable<FileDto> files)
|
||||||
|
{
|
||||||
|
_Http = http;
|
||||||
|
_CacheFolder = cacheFolder;
|
||||||
|
_Files = new Dictionary<string, FileDto>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
_Files[GetLocalPath(cacheFolder, file.Path)] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLocalPath(string cacheFolder, string relativePath)
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(Path.Combine(cacheFolder, relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileDto _GetRemote(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDto file;
|
||||||
|
return _Files.TryGetValue(Path.GetFullPath(path), out file) ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the local copy matches the server file (same size and modification time).</summary>
|
||||||
|
public static bool IsCurrent(string localPath, FileDto file)
|
||||||
|
{
|
||||||
|
var info = new FileInfo(localPath);
|
||||||
|
if (!info.Exists || info.Length != file.Size)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modified = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds();
|
||||||
|
return Math.Abs(modified - file.Modified) < 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string path)
|
||||||
|
{
|
||||||
|
return _GetRemote(path) != null || File.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLocal(string path)
|
||||||
|
{
|
||||||
|
var file = _GetRemote(path);
|
||||||
|
return file == null ? File.Exists(path) : IsCurrent(_LocalPathOf(file), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _LocalPathOf(FileDto file)
|
||||||
|
{
|
||||||
|
return GetLocalPath(_CacheFolder, file.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RequestAsync(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var tasks = new List<Task>();
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var file = _GetRemote(path);
|
||||||
|
if (file == null || IsCurrent(_LocalPathOf(file), file))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localPath = _LocalPathOf(file);
|
||||||
|
// Lazy: GetOrAdd may call the factory twice under contention, but only one download may start.
|
||||||
|
tasks.Add(_Downloads.GetOrAdd(localPath, _ => new Lazy<Task>(() => _DownloadAsync(file, localPath))).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float GetProgress(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
long total = 0, done = 0;
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
var file = _GetRemote(path);
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += file.Size;
|
||||||
|
var localPath = _LocalPathOf(file);
|
||||||
|
long bytes;
|
||||||
|
if (IsCurrent(localPath, file))
|
||||||
|
{
|
||||||
|
done += file.Size;
|
||||||
|
}
|
||||||
|
else if (_BytesDone.TryGetValue(localPath, out bytes))
|
||||||
|
{
|
||||||
|
done += Math.Min(bytes, file.Size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total == 0 ? 1f : (float)done / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task _DownloadAsync(FileDto file, string localPath)
|
||||||
|
{
|
||||||
|
await _Parallel.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
|
||||||
|
var partPath = localPath + ".part";
|
||||||
|
long offset = File.Exists(partPath) ? new FileInfo(partPath).Length : 0;
|
||||||
|
if (offset >= file.Size)
|
||||||
|
{
|
||||||
|
offset = 0; // stale part file
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var request = new HttpRequestMessage(HttpMethod.Get, CSongServer.FileUrl(file.Path)))
|
||||||
|
{
|
||||||
|
if (offset > 0)
|
||||||
|
{
|
||||||
|
// Resume an interrupted download, but only if the file hasn't changed since.
|
||||||
|
request.Headers.Range = new RangeHeaderValue(offset, null);
|
||||||
|
request.Headers.IfRange = new RangeConditionHeaderValue(DateTimeOffset.FromUnixTimeMilliseconds(file.Modified));
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var response = await _Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var append = offset > 0 && response.StatusCode == HttpStatusCode.PartialContent;
|
||||||
|
if (!append)
|
||||||
|
{
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_BytesDone[localPath] = offset;
|
||||||
|
using (var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
|
||||||
|
using (var target = new FileStream(partPath, append ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
|
||||||
|
{
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
int read;
|
||||||
|
while ((read = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
|
||||||
|
{
|
||||||
|
await target.WriteAsync(buffer, 0, read).ConfigureAwait(false);
|
||||||
|
_BytesDone.AddOrUpdate(localPath, read, (_, v) => v + read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(partPath, localPath, true);
|
||||||
|
File.SetLastWriteTimeUtc(localPath, DateTimeOffset.FromUnixTimeMilliseconds(file.Modified).UtcDateTime);
|
||||||
|
if (!IsCurrent(localPath, file))
|
||||||
|
{
|
||||||
|
CLog.Warning("Song server file {File} has an unexpected size after download", CLog.Params(file.Path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
CLog.Error(e, "Downloading {File} from the song server failed: {ExceptionMessage}", CLog.Params(file.Path, e.Message));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_BytesDone.TryRemove(localPath, out _);
|
||||||
|
_Downloads.TryRemove(localPath, out _);
|
||||||
|
_Parallel.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
#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
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using VocaluxeLib.Log;
|
||||||
|
using VocaluxeLib.Songs;
|
||||||
|
using VocaluxeSongServer.Contracts;
|
||||||
|
|
||||||
|
namespace Vocaluxe.Base.SongServer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client of the song server (VocaluxeSongServer). The song text files are mirrored into a local cache folder,
|
||||||
|
/// which is then loaded like a normal song folder. Audio, video and images are downloaded on demand by
|
||||||
|
/// <see cref="CRemoteSongFileProvider" />. Without connection, the cached library of the last start is used.
|
||||||
|
/// </summary>
|
||||||
|
static class CSongServer
|
||||||
|
{
|
||||||
|
private const string _LibraryFileName = "library.json";
|
||||||
|
private const int _BundleSize = 1000;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _Json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||||
|
private static HttpClient _Http;
|
||||||
|
private static string _BaseUrl;
|
||||||
|
|
||||||
|
public static bool Enabled
|
||||||
|
{
|
||||||
|
get { return !string.IsNullOrWhiteSpace(CConfig.Config.SongServer.Url); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The local mirror of the server library, or null if no song server is configured.</summary>
|
||||||
|
public static string CacheFolder { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>True if the last sync reached the server, false if the cached library was used.</summary>
|
||||||
|
public static bool Online { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Song folders to load in addition to the local ones (the cache folder if a song server is configured).</summary>
|
||||||
|
public static IEnumerable<string> SongFolders
|
||||||
|
{
|
||||||
|
get { return CacheFolder != null ? new[] { CacheFolder } : new string[0]; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FileUrl(string relativePath)
|
||||||
|
{
|
||||||
|
return _BaseUrl + "/api/v1/files/" + string.Join("/", relativePath.Split('/').Select(Uri.EscapeDataString));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void _Init()
|
||||||
|
{
|
||||||
|
_BaseUrl = CConfig.Config.SongServer.Url.Trim().TrimEnd('/');
|
||||||
|
if (!_BaseUrl.Contains("://"))
|
||||||
|
{
|
||||||
|
_BaseUrl = "http://" + _BaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uri = new Uri(_BaseUrl);
|
||||||
|
var folderName = uri.Host + "_" + uri.Port;
|
||||||
|
CacheFolder = Path.GetFullPath(Path.Combine(CSettings.DataFolder, "SongServer", folderName));
|
||||||
|
Directory.CreateDirectory(CacheFolder);
|
||||||
|
|
||||||
|
_Http?.Dispose();
|
||||||
|
_Http = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }) { Timeout = TimeSpan.FromMinutes(30) };
|
||||||
|
if (!string.IsNullOrEmpty(CConfig.Config.SongServer.ApiKey))
|
||||||
|
{
|
||||||
|
_Http.DefaultRequestHeaders.Add("X-Api-Key", CConfig.Config.SongServer.ApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the library from the server and mirrors the song text files. Blocks; called by the song loader thread
|
||||||
|
/// before the song folders are read. Does nothing if no song server is configured.
|
||||||
|
/// </summary>
|
||||||
|
public static void Sync()
|
||||||
|
{
|
||||||
|
CacheFolder = null;
|
||||||
|
CSongFiles.Provider = new CLocalSongFileProvider();
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_Init();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
CLog.Error(e, "Invalid song server address {Url}: {ExceptionMessage}", CLog.Params(CConfig.Config.SongServer.Url, e.Message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (CBenchmark.Time("Sync song server"))
|
||||||
|
{
|
||||||
|
var libraryPath = Path.Combine(CacheFolder, _LibraryFileName);
|
||||||
|
var cached = _LoadCachedLibrary(libraryPath);
|
||||||
|
var library = _FetchLibrary(cached, libraryPath) ?? cached;
|
||||||
|
if (library == null)
|
||||||
|
{
|
||||||
|
CLog.Warning("Song server {Url} not reachable and no cached library", CLog.Params(_BaseUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Online)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_SyncTextFiles(library);
|
||||||
|
_RemoveStaleFiles(library);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
CLog.Error(e, "Syncing song text files from {Url} failed: {ExceptionMessage}", CLog.Params(_BaseUrl, e.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CSongFiles.Provider = new CRemoteSongFileProvider(_Http, CacheFolder, library.Files);
|
||||||
|
CLog.Information("Song server {Url}: {Songs} songs ({State})", CLog.Params(_BaseUrl, library.Songs.Count, Online ? "online" : "offline, cached library"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LibraryDto _LoadCachedLibrary(string libraryPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.Exists(libraryPath) ? JsonSerializer.Deserialize<LibraryDto>(File.ReadAllText(libraryPath), _Json) : null;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
CLog.Warning("Cached song server library {File} is unreadable: {ExceptionMessage}", CLog.Params(libraryPath, e.Message));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LibraryDto _FetchLibrary(LibraryDto cached, string libraryPath)
|
||||||
|
{
|
||||||
|
Online = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var request = new HttpRequestMessage(HttpMethod.Get, _BaseUrl + "/api/v1/library"))
|
||||||
|
using (var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)))
|
||||||
|
{
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("If-None-Match", "\"" + cached.Version + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var response = _Http.SendAsync(request, timeout.Token).Result)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotModified)
|
||||||
|
{
|
||||||
|
Online = true;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var json = response.Content.ReadAsStringAsync().Result;
|
||||||
|
var library = JsonSerializer.Deserialize<LibraryDto>(json, _Json);
|
||||||
|
File.WriteAllText(libraryPath + ".tmp", json, Encoding.UTF8);
|
||||||
|
File.Move(libraryPath + ".tmp", libraryPath, true);
|
||||||
|
Online = true;
|
||||||
|
return library;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
var inner = e is AggregateException ? e.InnerException ?? e : e;
|
||||||
|
CLog.Warning("Can't reach song server {Url}: {ExceptionMessage}", CLog.Params(_BaseUrl, inner.Message));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads the song text files that are missing or changed (as zip bundles).</summary>
|
||||||
|
private static void _SyncTextFiles(LibraryDto library)
|
||||||
|
{
|
||||||
|
var files = library.Files.ToDictionary(f => f.Path, StringComparer.Ordinal);
|
||||||
|
var missing = library.Songs
|
||||||
|
.Select(s => s.Path)
|
||||||
|
.Where(p => files.ContainsKey(p) && !CRemoteSongFileProvider.IsCurrent(CRemoteSongFileProvider.GetLocalPath(CacheFolder, p), files[p]))
|
||||||
|
.ToList();
|
||||||
|
if (missing.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLog.Information("Downloading {Count} song text files from the song server", CLog.Params(missing.Count));
|
||||||
|
for (var i = 0; i < missing.Count; i += _BundleSize)
|
||||||
|
{
|
||||||
|
var request = new BundleRequest { Paths = missing.Skip(i).Take(_BundleSize).ToList() };
|
||||||
|
var content = new StringContent(JsonSerializer.Serialize(request, _Json), Encoding.UTF8, "application/json");
|
||||||
|
using (var response = _Http.PostAsync(_BaseUrl + "/api/v1/files/bundle", content).Result)
|
||||||
|
{
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
using (var stream = response.Content.ReadAsStreamAsync().Result)
|
||||||
|
using (var zip = new ZipArchive(stream, ZipArchiveMode.Read))
|
||||||
|
{
|
||||||
|
foreach (var entry in zip.Entries)
|
||||||
|
{
|
||||||
|
FileDto file;
|
||||||
|
if (!files.TryGetValue(entry.FullName, out file))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localPath = CRemoteSongFileProvider.GetLocalPath(CacheFolder, file.Path);
|
||||||
|
if (!localPath.StartsWith(CacheFolder, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue; // never write outside the cache folder
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
|
||||||
|
entry.ExtractToFile(localPath, true);
|
||||||
|
File.SetLastWriteTimeUtc(localPath, DateTimeOffset.FromUnixTimeMilliseconds(file.Modified).UtcDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The cache mirrors the server: files that are no longer on the server are deleted (removed songs disappear).</summary>
|
||||||
|
private static void _RemoveStaleFiles(LibraryDto library)
|
||||||
|
{
|
||||||
|
var known = new HashSet<string>(library.Files.Select(f => CRemoteSongFileProvider.GetLocalPath(CacheFolder, f.Path)), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var libraryPath = Path.Combine(CacheFolder, _LibraryFileName);
|
||||||
|
foreach (var file in Directory.EnumerateFiles(CacheFolder, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (known.Contains(file) || string.Equals(file, libraryPath, StringComparison.OrdinalIgnoreCase) || file.EndsWith(".part", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
CLog.Warning("Can't delete {File} from the song cache: {ExceptionMessage}", CLog.Params(file, e.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -255,6 +255,64 @@ namespace Vocaluxe.Screens
|
|||||||
_Texts[_TextShortInfoTop].Visible = false;
|
_Texts[_TextShortInfoTop].Visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Song server: files of the current song being downloaded before it starts
|
||||||
|
private System.Threading.Tasks.Task _DownloadTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and starts the current song. Songs from the song server are downloaded first if needed;
|
||||||
|
/// UpdateGame() shows the progress and starts the song when the download is finished.
|
||||||
|
/// </summary>
|
||||||
|
private void _LoadAndStartCurrentSong()
|
||||||
|
{
|
||||||
|
var song = CGame.IsFinished() ? null : CGame.GetSong();
|
||||||
|
if (song != null)
|
||||||
|
{
|
||||||
|
var files = song.GetMediaFiles(CConfig.Config.Video.VideosInSongs == EOffOn.TR_CONFIG_ON);
|
||||||
|
if (!CSongFiles.AreLocal(files))
|
||||||
|
{
|
||||||
|
_DownloadTask = CSongFiles.RequestAsync(files);
|
||||||
|
_ShowDownloadProgress(song, files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_LoadCurrentSong();
|
||||||
|
_StartSong();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void _ShowDownloadProgress(CSong song, List<string> files)
|
||||||
|
{
|
||||||
|
var percent = (int)(CSongFiles.GetProgress(files) * 100);
|
||||||
|
_ShowInfoText(CBase.Language.Translate("TR_SCREENSING_DOWNLOADING").Replace("%s", song.Artist + " - " + song.Title).Replace("%p", percent.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns true while the current song is being downloaded.</summary>
|
||||||
|
private bool _UpdateDownload()
|
||||||
|
{
|
||||||
|
if (_DownloadTask == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var song = CGame.GetSong();
|
||||||
|
if (!_DownloadTask.IsCompleted && song != null)
|
||||||
|
{
|
||||||
|
_ShowDownloadProgress(song, song.GetMediaFiles(CConfig.Config.Video.VideosInSongs == EOffOn.TR_CONFIG_ON));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_DownloadTask = null;
|
||||||
|
if (song != null && !CSongFiles.IsLocal(song.GetMP3()))
|
||||||
|
{
|
||||||
|
CLog.Error("Song files of {Artist} - {Title} could not be downloaded, skipping the song", CLog.Params(song.Artist, song.Title));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video, backgrounds or cover may be missing after a failed download; the song still starts without them.
|
||||||
|
_LoadCurrentSong();
|
||||||
|
_StartSong();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void _ShowInfoText(String text)
|
private void _ShowInfoText(String text)
|
||||||
{
|
{
|
||||||
_Texts[_TextShortInfoTop].Text = text;
|
_Texts[_TextShortInfoTop].Text = text;
|
||||||
@@ -630,6 +688,7 @@ namespace Vocaluxe.Screens
|
|||||||
public override void OnClose()
|
public override void OnClose()
|
||||||
{
|
{
|
||||||
base.OnClose();
|
base.OnClose();
|
||||||
|
_DownloadTask = null;
|
||||||
_CloseSong();
|
_CloseSong();
|
||||||
if (_Webcam)
|
if (_Webcam)
|
||||||
{
|
{
|
||||||
@@ -643,6 +702,11 @@ namespace Vocaluxe.Screens
|
|||||||
#region update game
|
#region update game
|
||||||
public override bool UpdateGame()
|
public override bool UpdateGame()
|
||||||
{
|
{
|
||||||
|
if (_UpdateDownload())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var finish = false;
|
var finish = false;
|
||||||
if (CSound.IsPlaying(_CurrentStream) || CSound.IsPaused(_CurrentStream))
|
if (CSound.IsPlaying(_CurrentStream) || CSound.IsPaused(_CurrentStream))
|
||||||
{
|
{
|
||||||
@@ -1534,9 +1598,7 @@ namespace Vocaluxe.Screens
|
|||||||
_CloseSong();
|
_CloseSong();
|
||||||
|
|
||||||
CGame.ResetPlayer();
|
CGame.ResetPlayer();
|
||||||
_LoadCurrentSong();
|
_LoadAndStartCurrentSong();
|
||||||
|
|
||||||
_StartSong();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1551,9 +1613,7 @@ namespace Vocaluxe.Screens
|
|||||||
CGame.NextRound();
|
CGame.NextRound();
|
||||||
_SetVisibility();
|
_SetVisibility();
|
||||||
|
|
||||||
_LoadCurrentSong();
|
_LoadAndStartCurrentSong();
|
||||||
|
|
||||||
_StartSong();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1586,9 +1646,7 @@ namespace Vocaluxe.Screens
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_LoadCurrentSong();
|
_LoadAndStartCurrentSong();
|
||||||
|
|
||||||
_StartSong();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void _TogglePause()
|
private void _TogglePause()
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
<Compile Remove="Base\Font\**" />
|
<Compile Remove="Base\Font\**" />
|
||||||
<None Remove="Animations\**;Base\Font\**" />
|
<None Remove="Animations\**;Base\Font\**" />
|
||||||
<Compile Remove="Lib\Draw\CDirect3D.cs;Lib\Sound\Record\NAudio\CNAudioRecord.cs;Lib\Webcam\CAForgeNet.cs" Condition="'$(IsWindowsConfig)' != 'true'" />
|
<Compile Remove="Lib\Draw\CDirect3D.cs;Lib\Sound\Record\NAudio\CNAudioRecord.cs;Lib\Webcam\CAForgeNet.cs" Condition="'$(IsWindowsConfig)' != 'true'" />
|
||||||
|
<!-- API types of the song server, shared with VocaluxeSongServer -->
|
||||||
|
<Compile Include="..\VocaluxeSongServer\Contracts\*.cs" Link="Base\SongServer\Contracts\%(Filename)%(Extension)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -338,6 +338,41 @@ namespace VocaluxeLib.Songs
|
|||||||
return Path.Combine(Folder, Video);
|
return Path.Combine(Folder, Video);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string GetCover()
|
||||||
|
{
|
||||||
|
return Cover == "" ? "" : Path.Combine(Folder, Cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full paths of the files needed to sing the song: audio (or instrumental/vocals), video, backgrounds and cover.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> GetMediaFiles(bool includeVideo = true)
|
||||||
|
{
|
||||||
|
var files = new List<string> { GetMP3() };
|
||||||
|
if (HasInstrumental())
|
||||||
|
{
|
||||||
|
files.Add(GetInstrumental());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasVocals())
|
||||||
|
{
|
||||||
|
files.Add(GetVocals());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeVideo && !string.IsNullOrEmpty(Video))
|
||||||
|
{
|
||||||
|
files.Add(GetVideo());
|
||||||
|
}
|
||||||
|
|
||||||
|
files.AddRange(BackgroundFileNames.Where(bg => !string.IsNullOrEmpty(bg)).Select(bg => Path.Combine(Folder, bg)));
|
||||||
|
if (Cover != "")
|
||||||
|
{
|
||||||
|
files.Add(GetCover());
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
public void LoadSmallCover()
|
public void LoadSmallCover()
|
||||||
{
|
{
|
||||||
if (_CoverTextureSmall != null)
|
if (_CoverTextureSmall != null)
|
||||||
@@ -347,7 +382,9 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
if (Cover != "")
|
if (Cover != "")
|
||||||
{
|
{
|
||||||
if (CBase.DataBase.GetCover(Path.Combine(Folder, Cover), ref _CoverTextureSmall, CBase.Config.GetCoverSize()))
|
// Songs from a song server: download the cover first (cover loading runs on background threads).
|
||||||
|
CSongFiles.EnsureLocal(GetCover());
|
||||||
|
if (CBase.DataBase.GetCover(GetCover(), ref _CoverTextureSmall, CBase.Config.GetCoverSize()))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#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
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace VocaluxeLib.Songs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Access to the files of a song (audio, video, cover, backgrounds).
|
||||||
|
/// Songs from a song server exist locally only as their text file at first; their other files are
|
||||||
|
/// downloaded on demand. Everything that opens a song file should check <see cref="IsLocal" /> and
|
||||||
|
/// call <see cref="RequestAsync" /> (or <see cref="EnsureLocal" /> on background threads) first.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISongFileProvider
|
||||||
|
{
|
||||||
|
/// <summary>True if the file exists locally or can be downloaded.</summary>
|
||||||
|
bool Exists(string path);
|
||||||
|
|
||||||
|
/// <summary>True if the file can be opened now (exists locally and is complete).</summary>
|
||||||
|
bool IsLocal(string path);
|
||||||
|
|
||||||
|
/// <summary>Downloads the files that are not local yet. The task completes when all are local (or failed).</summary>
|
||||||
|
Task RequestAsync(IEnumerable<string> paths);
|
||||||
|
|
||||||
|
/// <summary>Progress of the downloads of these files in [0..1], 1 if all are local.</summary>
|
||||||
|
float GetProgress(IEnumerable<string> paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The default provider: plain local files.</summary>
|
||||||
|
public class CLocalSongFileProvider : ISongFileProvider
|
||||||
|
{
|
||||||
|
public bool Exists(string path)
|
||||||
|
{
|
||||||
|
return File.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLocal(string path)
|
||||||
|
{
|
||||||
|
return File.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RequestAsync(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float GetProgress(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
return 1f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CSongFiles
|
||||||
|
{
|
||||||
|
public static ISongFileProvider Provider { get; set; } = new CLocalSongFileProvider();
|
||||||
|
|
||||||
|
public static bool Exists(string path)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(path) && Provider.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsLocal(string path)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(path) && Provider.IsLocal(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool AreLocal(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
return paths.All(IsLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task RequestAsync(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var missing = paths.Where(p => !string.IsNullOrEmpty(p) && !Provider.IsLocal(p)).Distinct().ToList();
|
||||||
|
return missing.Count == 0 ? Task.CompletedTask : Provider.RequestAsync(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blocks until the file is local. Only for background threads (e.g. cover loading).</summary>
|
||||||
|
public static bool EnsureLocal(string path)
|
||||||
|
{
|
||||||
|
if (IsLocal(path))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Exists(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RequestAsync(new[] { path }).Wait();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// A failed download is reported by the provider; the caller handles the missing file.
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsLocal(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float GetProgress(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
return Provider.GetProgress(paths.Where(p => !string.IsNullOrEmpty(p)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,7 +194,7 @@ namespace VocaluxeLib.Songs
|
|||||||
break;
|
break;
|
||||||
case "MP3":
|
case "MP3":
|
||||||
case "AUDIO":
|
case "AUDIO":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.Audio = value;
|
_Song.Audio = value;
|
||||||
headerFlags |= EHeaderFlags.MP3;
|
headerFlags |= EHeaderFlags.MP3;
|
||||||
@@ -207,7 +207,7 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "INSTRUMENTAL":
|
case "INSTRUMENTAL":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.Instrumental = value;
|
_Song.Instrumental = value;
|
||||||
headerFlags |= EHeaderFlags.Instrumental;
|
headerFlags |= EHeaderFlags.Instrumental;
|
||||||
@@ -221,7 +221,7 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "VOCALS":
|
case "VOCALS":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.Vocals = value;
|
_Song.Vocals = value;
|
||||||
headerFlags |= EHeaderFlags.Vocals;
|
headerFlags |= EHeaderFlags.Vocals;
|
||||||
@@ -298,7 +298,7 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "COVER":
|
case "COVER":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.Cover = value;
|
_Song.Cover = value;
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "BACKGROUND":
|
case "BACKGROUND":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.BackgroundFileNames.Add(value);
|
_Song.BackgroundFileNames.Add(value);
|
||||||
}
|
}
|
||||||
@@ -321,7 +321,7 @@ namespace VocaluxeLib.Songs
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case "VIDEO":
|
case "VIDEO":
|
||||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||||
{
|
{
|
||||||
_Song.Video = value;
|
_Song.Video = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ namespace VocaluxeLib.Utils.Player
|
|||||||
{
|
{
|
||||||
var song = _Song;
|
var song = _Song;
|
||||||
return song != null && !string.IsNullOrEmpty(song.Folder) && !string.IsNullOrEmpty(song.Video) &&
|
return song != null && !string.IsNullOrEmpty(song.Folder) && !string.IsNullOrEmpty(song.Video) &&
|
||||||
File.Exists(Path.Combine(song.Folder, song.Video));
|
CSongFiles.IsLocal(song.GetVideo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#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
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
// Shared between the song server and the game (linked into Vocaluxe.csproj). Keep it free of dependencies.
|
||||||
|
// Serialized as camelCase JSON. All paths are relative to the library root and use '/'.
|
||||||
|
namespace VocaluxeSongServer.Contracts
|
||||||
|
{
|
||||||
|
/// <summary>GET /api/v1/library</summary>
|
||||||
|
public sealed class LibraryDto
|
||||||
|
{
|
||||||
|
/// <summary>Changes whenever any song file is added, removed or changed. Also sent as ETag.</summary>
|
||||||
|
public string Version { get; set; } = "";
|
||||||
|
public string ScannedAt { get; set; } = "";
|
||||||
|
public List<SongDto> Songs { get; set; } = new List<SongDto>();
|
||||||
|
/// <summary>All files in the song folders (text files, audio, video, images), downloadable via /api/v1/files/{path}.</summary>
|
||||||
|
public List<FileDto> Files { get; set; } = new List<FileDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SongDto
|
||||||
|
{
|
||||||
|
/// <summary>Stable id, derived from the path of the text file.</summary>
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
/// <summary>Path of the UltraStar text file.</summary>
|
||||||
|
public string Path { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileDto
|
||||||
|
{
|
||||||
|
public string Path { get; set; } = "";
|
||||||
|
public long Size { get; set; }
|
||||||
|
/// <summary>Last modification, Unix time in milliseconds (UTC).</summary>
|
||||||
|
public long Modified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/v1/files/bundle: returns the files as a zip archive (entries named by their path).</summary>
|
||||||
|
public sealed class BundleRequest
|
||||||
|
{
|
||||||
|
public List<string> Paths { get; set; } = new List<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Build from the repository root: docker build -f VocaluxeSongServer/Dockerfile -t vocaluxe-song-server .
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Directory.Build.props NuGet.Config ./
|
||||||
|
COPY VocaluxeSongServer/VocaluxeSongServer.csproj VocaluxeSongServer/
|
||||||
|
RUN dotnet restore VocaluxeSongServer/VocaluxeSongServer.csproj
|
||||||
|
COPY VocaluxeSongServer/ VocaluxeSongServer/
|
||||||
|
RUN dotnet publish VocaluxeSongServer/VocaluxeSongServer.csproj -c ReleaseLinux --no-restore -o /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app .
|
||||||
|
# The song library (read-only is enough); see README.md
|
||||||
|
VOLUME /songs
|
||||||
|
ENV Library__Path=/songs
|
||||||
|
EXPOSE 8080
|
||||||
|
USER $APP_UID
|
||||||
|
ENTRYPOINT ["dotnet", "VocaluxeSongServer.dll"]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace VocaluxeSongServer.Library
|
||||||
|
{
|
||||||
|
public sealed class CLibraryOptions
|
||||||
|
{
|
||||||
|
/// <summary>Root folder of the song library (in Docker: the mounted share, default /songs).</summary>
|
||||||
|
public string Path { get; set; } = "/songs";
|
||||||
|
/// <summary>Minutes between automatic rescans. 0 = only at startup and via POST /api/v1/library/rescan.</summary>
|
||||||
|
public int ScanIntervalMinutes { get; set; } = 10;
|
||||||
|
/// <summary>If set, every /api request needs the header X-Api-Key with this value.</summary>
|
||||||
|
public string ApiKey { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Holds the current library snapshot and rescans it in the background.</summary>
|
||||||
|
public sealed class CLibraryIndex : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly CLibraryScanner _Scanner;
|
||||||
|
private readonly CLibraryOptions _Options;
|
||||||
|
private readonly ILogger<CLibraryIndex> _Logger;
|
||||||
|
private readonly SemaphoreSlim _ScanLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _RescanRequested = new(0, 1);
|
||||||
|
private volatile CLibrarySnapshot? _Current;
|
||||||
|
|
||||||
|
public CLibraryIndex(CLibraryScanner scanner, IOptions<CLibraryOptions> options, ILogger<CLibraryIndex> logger)
|
||||||
|
{
|
||||||
|
_Scanner = scanner;
|
||||||
|
_Options = options.Value;
|
||||||
|
_Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last completed scan, or null while the first scan is running.</summary>
|
||||||
|
public CLibrarySnapshot? Current => _Current;
|
||||||
|
|
||||||
|
public string Root => System.IO.Path.GetFullPath(_Options.Path);
|
||||||
|
|
||||||
|
/// <summary>Asks for a rescan as soon as possible (returns immediately).</summary>
|
||||||
|
public void RequestRescan()
|
||||||
|
{
|
||||||
|
if (_RescanRequested.CurrentCount == 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_RescanRequested.Release();
|
||||||
|
}
|
||||||
|
catch (SemaphoreFullException)
|
||||||
|
{
|
||||||
|
// Already requested.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ScanNowAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _ScanLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(Root))
|
||||||
|
{
|
||||||
|
_Logger.LogError("Library folder {Root} does not exist", Root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var started = DateTime.UtcNow;
|
||||||
|
var snapshot = await Task.Run(() => _Scanner.Scan(Root), ct);
|
||||||
|
var changed = _Current?.Dto.Version != snapshot.Dto.Version;
|
||||||
|
_Current = snapshot;
|
||||||
|
_Logger.LogInformation("Scanned {Root}: {Songs} songs, {Files} files in {Seconds:0.0} s{Changed}", Root,
|
||||||
|
snapshot.Dto.Songs.Count, snapshot.Dto.Files.Count, (DateTime.UtcNow - started).TotalSeconds, changed ? "" : " (unchanged)");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ScanLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ScanNowAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_Logger.LogError(e, "Library scan failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval = _Options.ScanIntervalMinutes > 0 ? TimeSpan.FromMinutes(_Options.ScanIntervalMinutes) : Timeout.InfiniteTimeSpan;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _RescanRequested.WaitAsync(interval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using VocaluxeSongServer.Contracts;
|
||||||
|
using VocaluxeSongServer.Library;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Settings: appsettings.json section "Library", or environment variables Library__Path, Library__ApiKey, ...
|
||||||
|
builder.Services.Configure<CLibraryOptions>(builder.Configuration.GetSection("Library"));
|
||||||
|
builder.Services.AddSingleton<CLibraryScanner>();
|
||||||
|
builder.Services.AddSingleton<CLibraryIndex>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<CLibraryIndex>());
|
||||||
|
builder.Services.AddResponseCompression(o =>
|
||||||
|
{
|
||||||
|
o.EnableForHttps = true;
|
||||||
|
o.Providers.Add<GzipCompressionProvider>();
|
||||||
|
o.Providers.Add<BrotliCompressionProvider>();
|
||||||
|
});
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.UseResponseCompression();
|
||||||
|
|
||||||
|
app.MapGet("/health", (CLibraryIndex index) => index.Current == null
|
||||||
|
? Results.Json(new { status = "scanning" }, statusCode: 503)
|
||||||
|
: Results.Json(new { status = "ok", songs = index.Current.Dto.Songs.Count, version = index.Current.Dto.Version }));
|
||||||
|
|
||||||
|
var api = app.MapGroup("/api/v1");
|
||||||
|
api.AddEndpointFilter(async (context, next) =>
|
||||||
|
{
|
||||||
|
var key = context.HttpContext.RequestServices.GetRequiredService<IOptions<CLibraryOptions>>().Value.ApiKey;
|
||||||
|
if (!string.IsNullOrEmpty(key) && context.HttpContext.Request.Headers["X-Api-Key"] != key)
|
||||||
|
{
|
||||||
|
return Results.Problem(title: "Missing or wrong X-Api-Key", statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await next(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole library: songs and their files. Clients cache it and send If-None-Match.
|
||||||
|
api.MapGet("/library", (HttpContext ctx, CLibraryIndex index) =>
|
||||||
|
{
|
||||||
|
var snapshot = index.Current;
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
var etag = "\"" + snapshot.Dto.Version + "\"";
|
||||||
|
ctx.Response.Headers.ETag = etag;
|
||||||
|
if (ctx.Request.Headers.IfNoneMatch == etag)
|
||||||
|
{
|
||||||
|
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(snapshot.Dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapPost("/library/rescan", (CLibraryIndex index) =>
|
||||||
|
{
|
||||||
|
index.RequestRescan();
|
||||||
|
return Results.Accepted();
|
||||||
|
});
|
||||||
|
|
||||||
|
// One file of the library. Only files listed in the library are served (no path tricks possible).
|
||||||
|
// Supports Range requests, so clients can resume downloads.
|
||||||
|
var contentTypes = new FileExtensionContentTypeProvider();
|
||||||
|
api.MapGet("/files/{**path}", (string path, CLibraryIndex index) =>
|
||||||
|
{
|
||||||
|
var snapshot = index.Current;
|
||||||
|
if (snapshot == null || !snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentTypes.TryGetContentType(fullPath, out var contentType))
|
||||||
|
{
|
||||||
|
contentType = "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.File(fullPath, contentType, lastModified: File.GetLastWriteTimeUtc(fullPath), enableRangeProcessing: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Several (small) files at once as a zip archive, e.g. all song text files on the first sync.
|
||||||
|
api.MapPost("/files/bundle", async (BundleRequest request, CLibraryIndex index, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var snapshot = index.Current;
|
||||||
|
if (snapshot == null)
|
||||||
|
{
|
||||||
|
return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxFiles = 2000;
|
||||||
|
if (request.Paths.Count > maxFiles)
|
||||||
|
{
|
||||||
|
return Results.Problem(title: $"At most {maxFiles} files per bundle", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, true))
|
||||||
|
{
|
||||||
|
foreach (var path in request.Paths.Distinct())
|
||||||
|
{
|
||||||
|
if (!snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = zip.CreateEntry(path, CompressionLevel.Fastest);
|
||||||
|
entry.LastWriteTime = File.GetLastWriteTimeUtc(fullPath);
|
||||||
|
await using var source = File.OpenRead(fullPath);
|
||||||
|
await using var target = entry.Open();
|
||||||
|
await source.CopyToAsync(target, ctx.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
return Results.File(buffer, "application/zip", "bundle.zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program { }
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Vocaluxe song server
|
||||||
|
|
||||||
|
A central song library for Vocaluxe. The server hosts the UltraStar song folders (text file, audio, video, cover,
|
||||||
|
backgrounds). Vocaluxe mirrors the song text files at startup and downloads audio, video and images only when they are
|
||||||
|
needed, then keeps them in a local cache.
|
||||||
|
|
||||||
|
## Running it (Docker)
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --name vocaluxe-song-server -p 8099:8080 \
|
||||||
|
-v /mnt/user/karaoke/songs:/songs:ro \
|
||||||
|
git.disi.dev/projects/vocaluxe-song-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting (environment variable) | Default | Meaning |
|
||||||
|
|---------------------------------|----------|----------------------------------------------------------------------|
|
||||||
|
| `Library__Path` | `/songs` | Root folder of the song library; any folder structure below it works |
|
||||||
|
| `Library__ScanIntervalMinutes` | `10` | Rescan interval; `0` = only at startup and via the rescan endpoint |
|
||||||
|
| `Library__ApiKey` | empty | If set, clients must send it as `X-Api-Key` |
|
||||||
|
|
||||||
|
A song is any `.txt` file with `#TITLE`, `#ARTIST` and `#MP3` (or `#AUDIO`). All files in its folder are served.
|
||||||
|
Files outside song folders are never served.
|
||||||
|
|
||||||
|
## Using it in Vocaluxe
|
||||||
|
|
||||||
|
In `config.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<SongServer>
|
||||||
|
<Url>http://192.168.1.108:8099</Url>
|
||||||
|
<ApiKey />
|
||||||
|
</SongServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Local song folders keep working alongside the server. The cache is in the Vocaluxe data folder under
|
||||||
|
`SongServer/<host>_<port>`; it mirrors the server, so songs removed on the server disappear from it. Without a
|
||||||
|
connection Vocaluxe uses the library of the last start, and every song whose files were downloaded before can be sung.
|
||||||
|
|
||||||
|
What is downloaded when:
|
||||||
|
* **Startup:** the song list and all song text files (only changed ones after the first time). Covers are downloaded by
|
||||||
|
the cover loader in the background.
|
||||||
|
* **Song selected (preview):** the audio file; the preview starts when it's there. The video is downloaded in the
|
||||||
|
background.
|
||||||
|
* **Song started:** everything still missing (video, instrumental/vocals, backgrounds). The sing screen shows the
|
||||||
|
progress and starts the song when the download is finished.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are under `/api/v1` (plus `GET /health`). Paths are relative to the library root and use `/`.
|
||||||
|
The types are in `Contracts/LibraryDtos.cs`, which Vocaluxe links in.
|
||||||
|
|
||||||
|
| Endpoint | What it does |
|
||||||
|
|------------------------------|-------------------------------------------------------------------------------------|
|
||||||
|
| `GET /library` | Songs (id, path, title, artist) and all files (path, size, modified); ETag/304 |
|
||||||
|
| `GET /files/{path}` | One file, with Range support (resumable downloads) |
|
||||||
|
| `POST /files/bundle` | Several files as a zip, body `{"paths": [...]}` (max. 2000), used for text files |
|
||||||
|
| `POST /library/rescan` | Rescan now instead of waiting for the interval |
|
||||||
|
|
||||||
|
## Building and releasing
|
||||||
|
|
||||||
|
`dotnet run --project VocaluxeSongServer -- --Library:Path=D:\Songs --urls http://localhost:8099` runs it locally.
|
||||||
|
The image is built by `.gitea/workflows/song-server.yml`: set `VERSION`, then push the tag `songserver-v<version>`
|
||||||
|
(pushes `:<version>` and `:latest`). Tests: `Tests/SongServer`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<!-- The song server: hosts the song library (UltraStar folders) for Vocaluxe clients. Runs in Docker on Linux,
|
||||||
|
see README.md. Target framework and configurations come from ../Directory.Build.props. -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>VocaluxeSongServer</RootNamespace>
|
||||||
|
<AssemblyName>VocaluxeSongServer</AssemblyName>
|
||||||
|
<Platforms>AnyCPU</Platforms>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<CodeAnalysisRuleSet></CodeAnalysisRuleSet>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Urls": "http://0.0.0.0:8080",
|
||||||
|
"Library": {
|
||||||
|
"Path": "/songs",
|
||||||
|
"ScanIntervalMinutes": 10,
|
||||||
|
"ApiKey": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user