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,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