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_VOCALSVOLUME">Vocals track volume</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_ASPECTRATIO">Aspect Ratio: %a</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="NunitXml.TestLogger" Version="8.1.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="10.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Update="VocaluxeLib\Log\CLogTest.cs">
|
||||
@@ -32,5 +33,7 @@
|
||||
<ProjectReference Include="..\VocaluxeLib\VocaluxeLib.csproj" SetPlatform="Platform=AnyCPU" />
|
||||
<ProjectReference Include="..\Vocaluxe\Vocaluxe.csproj" />
|
||||
<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>
|
||||
</Project>
|
||||
@@ -137,4 +137,8 @@
|
||||
<ServerPort>3000</ServerPort>
|
||||
<SongCountCoverThreshold>70</SongCountCoverThreshold>
|
||||
</Server>
|
||||
<SongServer>
|
||||
<Url />
|
||||
<ApiKey />
|
||||
</SongServer>
|
||||
</root>
|
||||
@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeTournament", "Part
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeWeb", "VocaluxeWeb\VocaluxeWeb.csproj", "{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}"
|
||||
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}"
|
||||
EndProject
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using VocaluxeLib;
|
||||
using VocaluxeLib.Draw;
|
||||
@@ -55,6 +56,11 @@ namespace Vocaluxe.Base
|
||||
private const int _PreviewStartWaitMaxTries = 10;
|
||||
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
|
||||
{
|
||||
get { return _CurPlayer.VideoEnabled; }
|
||||
@@ -309,6 +315,16 @@ namespace Vocaluxe.Base
|
||||
|
||||
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))
|
||||
{
|
||||
lock (_PreviewStartHelperTaskLock)
|
||||
@@ -419,6 +435,27 @@ namespace Vocaluxe.Base
|
||||
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);
|
||||
|
||||
lock (_PreviewStartHelperTaskLock)
|
||||
@@ -456,6 +493,7 @@ namespace Vocaluxe.Base
|
||||
|
||||
public static void StopPreview()
|
||||
{
|
||||
_PendingPreviewSong = null;
|
||||
if (!IsPlayingPreview)
|
||||
{
|
||||
return;
|
||||
@@ -520,7 +558,8 @@ namespace Vocaluxe.Base
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -249,6 +249,14 @@ namespace Vocaluxe.Base
|
||||
[DefaultValue(70)]
|
||||
public int SongCountCoverThreshold;
|
||||
}
|
||||
|
||||
public struct SConfigSongServer
|
||||
{
|
||||
[DefaultValue("")]
|
||||
public string Url;
|
||||
[DefaultValue("")]
|
||||
public string ApiKey;
|
||||
}
|
||||
#pragma warning restore 649
|
||||
// ReSharper restore UnassignedField.Global
|
||||
|
||||
@@ -265,6 +273,7 @@ namespace Vocaluxe.Base
|
||||
public SConfigVideo Video;
|
||||
public SConfigRecord Record;
|
||||
public SConfigServer Server;
|
||||
public SConfigSongServer SongServer;
|
||||
}
|
||||
|
||||
public static SConfig Config;
|
||||
@@ -643,6 +652,10 @@ namespace Vocaluxe.Base
|
||||
case "SongCountCoverThreshold":
|
||||
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";
|
||||
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":
|
||||
return "Stretch view to full window size: " + CHelper.ListStrings(Enum.GetNames(typeof(EOffOn)));
|
||||
default:
|
||||
|
||||
@@ -19,6 +19,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using Vocaluxe.Base.Fonts;
|
||||
using Vocaluxe.Base.ThemeSystem;
|
||||
using VocaluxeLib;
|
||||
@@ -133,7 +134,7 @@ namespace Vocaluxe.Base
|
||||
|
||||
public IEnumerable<string> GetSongFolders()
|
||||
{
|
||||
return CConfig.SongFolders;
|
||||
return CConfig.SongFolders.Concat(SongServer.CSongServer.SongFolders);
|
||||
}
|
||||
|
||||
public ESongSorting GetSongSorting()
|
||||
|
||||
@@ -23,6 +23,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Vocaluxe.Base.SongServer;
|
||||
using VocaluxeLib;
|
||||
using VocaluxeLib.Log;
|
||||
using VocaluxeLib.Songs;
|
||||
@@ -418,11 +419,14 @@ namespace Vocaluxe.Base
|
||||
|
||||
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"))
|
||||
{
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
_Texts[_TextShortInfoTop].Text = text;
|
||||
@@ -630,6 +688,7 @@ namespace Vocaluxe.Screens
|
||||
public override void OnClose()
|
||||
{
|
||||
base.OnClose();
|
||||
_DownloadTask = null;
|
||||
_CloseSong();
|
||||
if (_Webcam)
|
||||
{
|
||||
@@ -643,6 +702,11 @@ namespace Vocaluxe.Screens
|
||||
#region update game
|
||||
public override bool UpdateGame()
|
||||
{
|
||||
if (_UpdateDownload())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var finish = false;
|
||||
if (CSound.IsPlaying(_CurrentStream) || CSound.IsPaused(_CurrentStream))
|
||||
{
|
||||
@@ -1534,9 +1598,7 @@ namespace Vocaluxe.Screens
|
||||
_CloseSong();
|
||||
|
||||
CGame.ResetPlayer();
|
||||
_LoadCurrentSong();
|
||||
|
||||
_StartSong();
|
||||
_LoadAndStartCurrentSong();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1551,9 +1613,7 @@ namespace Vocaluxe.Screens
|
||||
CGame.NextRound();
|
||||
_SetVisibility();
|
||||
|
||||
_LoadCurrentSong();
|
||||
|
||||
_StartSong();
|
||||
_LoadAndStartCurrentSong();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1586,9 +1646,7 @@ namespace Vocaluxe.Screens
|
||||
}
|
||||
|
||||
|
||||
_LoadCurrentSong();
|
||||
|
||||
_StartSong();
|
||||
_LoadAndStartCurrentSong();
|
||||
}
|
||||
|
||||
private void _TogglePause()
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<Compile Remove="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'" />
|
||||
<!-- API types of the song server, shared with VocaluxeSongServer -->
|
||||
<Compile Include="..\VocaluxeSongServer\Contracts\*.cs" Link="Base\SongServer\Contracts\%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -338,6 +338,41 @@ namespace VocaluxeLib.Songs
|
||||
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()
|
||||
{
|
||||
if (_CoverTextureSmall != null)
|
||||
@@ -347,7 +382,9 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
case "MP3":
|
||||
case "AUDIO":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.Audio = value;
|
||||
headerFlags |= EHeaderFlags.MP3;
|
||||
@@ -207,7 +207,7 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
break;
|
||||
case "INSTRUMENTAL":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.Instrumental = value;
|
||||
headerFlags |= EHeaderFlags.Instrumental;
|
||||
@@ -221,7 +221,7 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
break;
|
||||
case "VOCALS":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.Vocals = value;
|
||||
headerFlags |= EHeaderFlags.Vocals;
|
||||
@@ -298,7 +298,7 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
break;
|
||||
case "COVER":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.Cover = value;
|
||||
}
|
||||
@@ -309,7 +309,7 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
break;
|
||||
case "BACKGROUND":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.BackgroundFileNames.Add(value);
|
||||
}
|
||||
@@ -321,7 +321,7 @@ namespace VocaluxeLib.Songs
|
||||
|
||||
break;
|
||||
case "VIDEO":
|
||||
if (File.Exists(Path.Combine(_Song.Folder, value)))
|
||||
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
|
||||
{
|
||||
_Song.Video = value;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace VocaluxeLib.Utils.Player
|
||||
{
|
||||
var song = _Song;
|
||||
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