Song server: search and download songs, mobile web UI
- Sources: "free" (Creative Commons songs with media from github.com/UltraStar-Deluxe/songs) and "usdb" (usdb.animux.de with an account; audio/video via yt-dlp from the song's #VIDEO hints or YouTube links in the comments, covers from fanart.tv/YouTube) - Aggregated search (all sources in parallel, in-library flag), download queue into <library>/Downloads/<source>/<Artist - Title>, then rescan - Web UI in wwwroot (plain JS, no build): search, downloads with progress, library; phone first, dark mode, installable - Docker image with yt-dlp, ffmpeg and deno; library mounted writable - Tests for matching, USDB parsing and song rewriting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
efcc63a640
commit
1e65864c8f
19 files changed
+1862
-12
No files matched your search
@@ -8,15 +8,15 @@ hook-based, so upstream can still be merged.
|
||||
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).
|
||||
3. Search and download songs that aren't in the library (started: server web UI with the sources `free` and `usdb`;
|
||||
next: more sources, 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` |
|
||||
| `VocaluxeSongServer/` | ASP.NET Core server, Docker image `git.disi.dev/projects/vocaluxe-song-server`. `Sources/`: song search and downloads (free CC songs from GitHub, USDB + yt-dlp). `wwwroot/`: mobile web UI (plain JS, no build) |
|
||||
| `VocaluxeWeb/`, `WebApp/` | Built-in webserver of the game and the guest WebApp (Vue), upstream |
|
||||
| `Tests/` | NUnit; song server tests in `Tests/SongServer` |
|
||||
|
||||
@@ -32,6 +32,12 @@ hook-based, so upstream can still be merged.
|
||||
- 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`.
|
||||
|
||||
## Song server web UI and sources
|
||||
- The web UI is plain HTML/CSS/JS in `VocaluxeSongServer/wwwroot` (no build step, no external requests except cover
|
||||
images). Check it at phone width: headless Edge has a ~500px minimum window, so render the page in a 390px iframe.
|
||||
- USDB can't be tested without an account (search and text download need a login). Its HTML parsing is covered by
|
||||
`Tests/SongServer/CSongSourcesTest.cs`; if USDB changes its HTML, fix `CUsdbSongSource.ParseList` there.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using CSourceHelper = songserver::VocaluxeSongServer.Sources.CSourceHelper;
|
||||
using CUsdbSongSource = songserver::VocaluxeSongServer.Sources.CUsdbSongSource;
|
||||
using CYtDlp = songserver::VocaluxeSongServer.Sources.CYtDlp;
|
||||
|
||||
namespace Tests.SongServer
|
||||
{
|
||||
[TestFixture]
|
||||
public class CSongSourcesTest
|
||||
{
|
||||
[Test]
|
||||
public void TestNormalizeAndMatch()
|
||||
{
|
||||
Assert.That(CSourceHelper.Normalize("Beyoncé – Halo!"), Is.EqualTo("beyonce halo"));
|
||||
Assert.That(CSourceHelper.Matches("halo beyonce", "Beyoncé - Halo"), Is.True);
|
||||
Assert.That(CSourceHelper.Matches("hal", "Beyoncé - Halo"), Is.True, "prefix of a word");
|
||||
Assert.That(CSourceHelper.Matches("queen", "Beyoncé - Halo"), Is.False);
|
||||
Assert.That(CSourceHelper.SafeName("AC/DC: Back in Black?"), Is.EqualTo("AC_DC_ Back in Black_"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestUsdbList()
|
||||
{
|
||||
// Shape of usdb.animux.de "link=list" with details=1 (sample and cover cells have no text)
|
||||
const string html = @"<table>
|
||||
<tr class=""list_tr1"" data-songid=""1234"" data-lastchange=""1700000000"">
|
||||
<td><audio><source src=""x.mp3""></audio></td><td><img src=""data/cover/1234.jpg""></td>
|
||||
<td><a href=""?link=byartist"">Queen</a></td><td><a href=""?link=detail&id=1234"">Don't Stop Me Now</a></td>
|
||||
<td>Rock</td><td>1978</td><td>SingStar</td><td>Yes</td><td>English</td><td>someone</td><td>5</td><td>1000</td>
|
||||
</tr>
|
||||
<tr class=""list_tr2"" data-songid=""99""><td></td><td></td><td>ABBA</td><td>Waterloo</td><td></td><td></td></tr>
|
||||
</table>";
|
||||
var results = CUsdbSongSource.ParseList(html);
|
||||
Assert.That(results.Select(r => r.SourceId), Is.EqualTo(new[] { "1234", "99" }));
|
||||
Assert.That(results[0].Artist, Is.EqualTo("Queen"));
|
||||
Assert.That(results[0].Title, Is.EqualTo("Don't Stop Me Now"));
|
||||
Assert.That(results[0].Details, Does.Contain("1978"));
|
||||
Assert.That(results[1].Title, Is.EqualTo("Waterloo"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestVideoTagAndImages()
|
||||
{
|
||||
var hints = CUsdbSongSource.ParseVideoTag("v=dQw4w9WgXcQ,a=https://youtu.be/aaaaaaaaaaa,co=artist/123/cover.jpg,bg=12345.jpg,co-rotate=5");
|
||||
Assert.That(hints.Video, Is.EqualTo("dQw4w9WgXcQ"));
|
||||
Assert.That(CYtDlp.YouTubeId(hints.Audio!), Is.EqualTo("aaaaaaaaaaa"));
|
||||
Assert.That(CUsdbSongSource.ImageUrl(hints.Cover!), Is.EqualTo("https://artist/123/cover.jpg"));
|
||||
Assert.That(CUsdbSongSource.ImageUrl(hints.Background!), Is.EqualTo("https://assets.fanart.tv/fanart/12345.jpg"));
|
||||
Assert.That(CUsdbSongSource.ImageUrl("https://images.fanart.tv/x.jpg"), Is.EqualTo("https://assets.fanart.tv/x.jpg"));
|
||||
Assert.That(CUsdbSongSource.ParseVideoTag("Queen - Song.mp4").Video, Is.Null, "a plain file name is no hint");
|
||||
|
||||
Assert.That(CYtDlp.FindYouTubeIds("see <a href=\"https://www.youtube.com/watch?v=abcdefghijk&t=1\">x</a> or youtu.be/abcdefghijk and https://youtube.com/embed/ZZZZZZZZZZZ"),
|
||||
Is.EqualTo(new[] { "abcdefghijk", "ZZZZZZZZZZZ" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestRewriteSong()
|
||||
{
|
||||
const string text = "#TITLE:Song\r\n#ARTIST:Band\r\n#MP3:Band - Song.mp3\r\n#VIDEO:v=dQw4w9WgXcQ,co=1.jpg\r\n#BPM:300\r\n: 0 4 5 La\r\nE\r\n";
|
||||
var result = CUsdbSongSource.RewriteSong(text, new Dictionary<string, string?> { ["MP3"] = "audio.m4a", ["VIDEO"] = "video.mp4", ["COVER"] = null });
|
||||
Assert.That(result, Is.EqualTo("#TITLE:Song\n#ARTIST:Band\n#BPM:300\n#MP3:audio.m4a\n#VIDEO:video.mp4\n: 0 4 5 La\nE\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ using System.Runtime.InteropServices;
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("Vocaluxe")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2025")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2026")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
|
||||
@@ -55,4 +55,62 @@ namespace VocaluxeSongServer.Contracts
|
||||
{
|
||||
public List<string> Paths { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v1/sources: where new songs can be found.</summary>
|
||||
public sealed class SourceDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public bool Enabled { get; set; }
|
||||
/// <summary>Why the source is disabled (e.g. missing account), or empty.</summary>
|
||||
public string Hint { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v1/search?q=...: results of all enabled sources.</summary>
|
||||
public sealed class SearchResponse
|
||||
{
|
||||
public List<SearchResultDto> Results { get; set; } = new List<SearchResultDto>();
|
||||
/// <summary>Sources that failed for this search: source id -> error message.</summary>
|
||||
public Dictionary<string, string> Errors { get; set; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public sealed class SearchResultDto
|
||||
{
|
||||
public string Source { get; set; } = "";
|
||||
/// <summary>Id of the song within its source; used to download it.</summary>
|
||||
public string SourceId { get; set; } = "";
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
/// <summary>Extra info shown with the result (language, year, license, ...).</summary>
|
||||
public string Details { get; set; } = "";
|
||||
public string CoverUrl { get; set; } = "";
|
||||
/// <summary>True if a song with the same artist and title is already in the library.</summary>
|
||||
public bool InLibrary { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST /api/v1/downloads</summary>
|
||||
public sealed class DownloadRequest
|
||||
{
|
||||
public string Source { get; set; } = "";
|
||||
public string SourceId { get; set; } = "";
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>GET /api/v1/downloads: queued, running and finished downloads (newest first).</summary>
|
||||
public sealed class DownloadJobDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
public string SourceId { get; set; } = "";
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
/// <summary>queued, running, done or failed</summary>
|
||||
public string State { get; set; } = "";
|
||||
/// <summary>0..1</summary>
|
||||
public double Progress { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public string Created { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,22 @@ COPY VocaluxeSongServer/ VocaluxeSongServer/
|
||||
RUN dotnet publish VocaluxeSongServer/VocaluxeSongServer.csproj -c ReleaseLinux --no-restore -o /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
# Song downloads (USDB source): yt-dlp for audio/video, ffmpeg for merging, deno as the JavaScript runtime
|
||||
# yt-dlp needs for YouTube. yt-dlp is the newest release at build time; rebuild the image to update it.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl unzip \
|
||||
&& curl -fsSL -o /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
|
||||
&& chmod 755 /usr/local/bin/yt-dlp \
|
||||
&& curl -fsSL -o /tmp/deno.zip https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \
|
||||
&& unzip -q /tmp/deno.zip -d /usr/local/bin && chmod 755 /usr/local/bin/deno && rm /tmp/deno.zip \
|
||||
&& apt-get purge -y unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
# The song library (read-only is enough); see README.md
|
||||
# The song library; must be writable for downloads (new songs go to <library>/Downloads)
|
||||
VOLUME /songs
|
||||
ENV Library__Path=/songs
|
||||
ENV Library__Path=/songs \
|
||||
HOME=/tmp \
|
||||
XDG_CACHE_HOME=/tmp/cache
|
||||
EXPOSE 8080
|
||||
USER $APP_UID
|
||||
# Runs as the user given with --user (on Unraid 99:100 = nobody:users, the owner of the shares)
|
||||
ENTRYPOINT ["dotnet", "VocaluxeSongServer.dll"]
|
||||
@@ -8,6 +8,8 @@ namespace VocaluxeSongServer.Library
|
||||
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>Folder below the library root for downloaded songs (one subfolder per source).</summary>
|
||||
public string DownloadFolder { get; set; } = "Downloads";
|
||||
/// <summary>If set, every /api request needs the header X-Api-Key with this value.</summary>
|
||||
public string ApiKey { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VocaluxeSongServer.Contracts;
|
||||
using VocaluxeSongServer.Library;
|
||||
using VocaluxeSongServer.Sources;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -13,6 +14,21 @@ builder.Services.Configure<CLibraryOptions>(builder.Configuration.GetSection("Li
|
||||
builder.Services.AddSingleton<CLibraryScanner>();
|
||||
builder.Services.AddSingleton<CLibraryIndex>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CLibraryIndex>());
|
||||
// Song sources for search and download (see Sources/). USDB needs Usdb__User and Usdb__Password.
|
||||
builder.Services.AddSingleton<CYtDlp>();
|
||||
builder.Services.AddHttpClient("sources", c => c.Timeout = TimeSpan.FromMinutes(10));
|
||||
builder.Services.AddSingleton<ISongSource>(sp => new CGitHubSongSource(sp.GetRequiredService<IHttpClientFactory>().CreateClient("sources")));
|
||||
builder.Services.AddSingleton<ISongSource>(sp => new CUsdbSongSource(
|
||||
new HttpClient(new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), AutomaticDecompression = System.Net.DecompressionMethods.All })
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2),
|
||||
DefaultRequestHeaders = { { "User-Agent", "VocaluxeSongServer" } }
|
||||
},
|
||||
builder.Configuration.GetSection("Usdb").Get<CUsdbOptions>() ?? new CUsdbOptions(),
|
||||
sp.GetRequiredService<CYtDlp>(), sp.GetRequiredService<ILogger<CUsdbSongSource>>()));
|
||||
builder.Services.AddSingleton<CSongSearch>();
|
||||
builder.Services.AddSingleton<CDownloadQueue>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CDownloadQueue>());
|
||||
builder.Services.AddResponseCompression(o =>
|
||||
{
|
||||
o.EnableForHttps = true;
|
||||
@@ -23,6 +39,9 @@ builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamin
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseResponseCompression();
|
||||
// The web UI (wwwroot): search and download songs, browse the library. Mobile first.
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapGet("/health", (CLibraryIndex index) => index.Current == null
|
||||
? Results.Json(new { status = "scanning" }, statusCode: 503)
|
||||
@@ -121,6 +140,28 @@ api.MapPost("/files/bundle", async (BundleRequest request, CLibraryIndex index,
|
||||
return Results.File(buffer, "application/zip", "bundle.zip");
|
||||
});
|
||||
|
||||
// Song search and downloads
|
||||
api.MapGet("/sources", (CSongSearch search) => search.Sources.Select(s => new SourceDto
|
||||
{
|
||||
Id = s.Id, Name = s.Name, Description = s.Description, Enabled = s.Enabled, Hint = s.Hint
|
||||
}));
|
||||
|
||||
api.MapGet("/search", (string q, CSongSearch search, CancellationToken ct) => search.SearchAsync(q, ct));
|
||||
|
||||
api.MapGet("/downloads", (CDownloadQueue queue) => queue.Jobs);
|
||||
|
||||
api.MapPost("/downloads", (DownloadRequest request, CDownloadQueue queue) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(queue.Enqueue(request));
|
||||
}
|
||||
catch (ArgumentException e)
|
||||
{
|
||||
return Results.Problem(title: e.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
@@ -4,11 +4,15 @@ A central song library for Vocaluxe. The server hosts the UltraStar song folders
|
||||
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.
|
||||
|
||||
The server also has a **web UI** (`http://<server>:8099/`, made for phones, can be added to the home screen): search
|
||||
song sources on the internet, download songs into the library with one tap, and browse the library.
|
||||
|
||||
## Running it (Docker)
|
||||
|
||||
```
|
||||
docker run -d --name vocaluxe-song-server -p 8099:8080 \
|
||||
-v /mnt/user/karaoke/songs:/songs:ro \
|
||||
docker run -d --name vocaluxe-song-server -p 8099:8080 --user 99:100 \
|
||||
-v /mnt/user/karaoke/songs:/songs \
|
||||
-e Usdb__User=... -e Usdb__Password=... \
|
||||
git.disi.dev/projects/vocaluxe-song-server:latest
|
||||
```
|
||||
|
||||
@@ -16,7 +20,12 @@ docker run -d --name vocaluxe-song-server -p 8099:8080 \
|
||||
|---------------------------------|----------|----------------------------------------------------------------------|
|
||||
| `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` |
|
||||
| `Library__ApiKey` | empty | If set, clients must send it as `X-Api-Key` (the web UI asks for it) |
|
||||
| `Library__DownloadFolder` | `Downloads` | Where downloaded songs go, below the library root (`<source>/<Artist - Title>`) |
|
||||
| `Usdb__User`, `Usdb__Password` | empty | Account at usdb.animux.de; enables the USDB source |
|
||||
|
||||
The library must be writable for downloads. The image contains yt-dlp (newest at build time; YouTube changes often,
|
||||
so rebuild the image when downloads start failing), ffmpeg and deno.
|
||||
|
||||
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.
|
||||
@@ -44,6 +53,25 @@ What is downloaded when:
|
||||
* **Song started:** everything still missing (video, instrumental/vocals, backgrounds). The sing screen shows the
|
||||
progress and starts the song when the download is finished.
|
||||
|
||||
## Song sources (search and download)
|
||||
|
||||
Research (2026-11): where UltraStar songs come from.
|
||||
|
||||
| Source | What it has | Used here |
|
||||
|---|---|---|
|
||||
| [UltraStar-Deluxe/songs](https://github.com/UltraStar-Deluxe/songs) | ~40 Creative Commons / public domain songs **with audio** and covers | **Yes**, source `free`, no account |
|
||||
| [USDB](https://usdb.animux.de) | The largest database (tens of thousands of songs), only the text files; free account needed. Songs reference audio/video (mostly YouTube) and covers (fanart.tv) in `#VIDEO` (`v=`, `a=`, `co=`, `bg=`) or in comments | **Yes**, source `usdb`: text from USDB, media via yt-dlp, like [usdb_syncer](https://github.com/bohning/usdb_syncer) |
|
||||
| [UltraStar-es](https://ultrastar-es.org/en/canciones) | Large Spanish database (also other languages) | Not yet |
|
||||
| [Performous packs](https://performous.org/songs) | 4 zip packs (libre, restricted, Jonathan Coulton, Shearer; CC licenses) | Not yet (Coulton songs are in `free`) |
|
||||
| TXT-only GitHub repos (razzertronic/usdx-songs, derco0n/UltrastarSongs, ...) | Text files without media | Not yet |
|
||||
| [UltraSinger](https://github.com/rakuri255/UltraSinger) | Creates a song from any audio with AI | Planned: songs that exist nowhere |
|
||||
|
||||
Songs, videos and lyrics can be copyrighted. The `free` source only has openly licensed songs; what you download from
|
||||
USDB/YouTube is your own responsibility.
|
||||
|
||||
New sources implement `Sources/ISongSource.cs` (search + download into a folder) and are registered in `Program.cs`.
|
||||
Downloads run one at a time; a song is downloaded into `Downloads/.incoming/<job>` and moved into place when complete.
|
||||
|
||||
## API
|
||||
|
||||
All endpoints are under `/api/v1` (plus `GET /health`). Paths are relative to the library root and use `/`.
|
||||
@@ -55,6 +83,10 @@ The types are in `Contracts/LibraryDtos.cs`, which Vocaluxe links in.
|
||||
| `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 |
|
||||
| `GET /sources` | Song sources, enabled or not (with the reason) |
|
||||
| `GET /search?q=` | Search all enabled sources at once; results say whether a song is in the library |
|
||||
| `GET /downloads` | Download jobs with state and progress |
|
||||
| `POST /downloads` | Queue a download: `{"source", "sourceId", "artist", "title"}` from a search result |
|
||||
|
||||
## Building and releasing
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using VocaluxeSongServer.Contracts;
|
||||
|
||||
namespace VocaluxeSongServer.Sources
|
||||
{
|
||||
/// <summary>
|
||||
/// Openly licensed songs with all media from a GitHub repository, by default UltraStar-Deluxe/songs
|
||||
/// (Creative Commons / public domain; one song per folder, license.txt in every folder).
|
||||
/// The repository tree is read with one API call and cached for a day (the API allows 60 calls/hour without token).
|
||||
/// </summary>
|
||||
public sealed class CGitHubSongSource : ISongSource
|
||||
{
|
||||
private sealed record CSong(string Folder, string Artist, string Title, string License, List<(string Path, long Size)> Files);
|
||||
|
||||
private readonly HttpClient _Http;
|
||||
private readonly string _Repo;
|
||||
private readonly string _Branch;
|
||||
private readonly SemaphoreSlim _Lock = new(1, 1);
|
||||
private List<CSong>? _Catalog;
|
||||
private DateTime _CatalogTime;
|
||||
|
||||
public CGitHubSongSource(HttpClient http, string repo = "UltraStar-Deluxe/songs", string branch = "master")
|
||||
{
|
||||
_Http = http;
|
||||
_Repo = repo;
|
||||
_Branch = branch;
|
||||
}
|
||||
|
||||
public string Id => "free";
|
||||
public string Name => "Free songs";
|
||||
public string Description => "Creative Commons and public domain songs with audio, from github.com/" + _Repo;
|
||||
public bool Enabled => true;
|
||||
public string Hint => "";
|
||||
|
||||
private async Task<List<CSong>> _GetCatalogAsync(CancellationToken ct)
|
||||
{
|
||||
await _Lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_Catalog != null && DateTime.UtcNow - _CatalogTime < TimeSpan.FromDays(1))
|
||||
{
|
||||
return _Catalog;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{_Repo}/git/trees/{_Branch}?recursive=1");
|
||||
request.Headers.UserAgent.ParseAdd("VocaluxeSongServer");
|
||||
using var response = await _Http.SendAsync(request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
|
||||
|
||||
var files = doc.RootElement.GetProperty("tree").EnumerateArray()
|
||||
.Where(e => e.GetProperty("type").GetString() == "blob")
|
||||
.Select(e => (Path: e.GetProperty("path").GetString()!, Size: e.TryGetProperty("size", out var s) ? s.GetInt64() : 0))
|
||||
.ToList();
|
||||
|
||||
var songs = new List<CSong>();
|
||||
foreach (var folder in files.GroupBy(f => f.Path.Contains('/') ? f.Path[..f.Path.LastIndexOf('/')] : ""))
|
||||
{
|
||||
var hasSong = folder.Any(f => f.Path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) &&
|
||||
!f.Path.EndsWith("/license.txt", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasSong || folder.Key == "")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Folder names are "Artist - Title"; the category is the parent folder ("Creative Commons", ...).
|
||||
var name = folder.Key[(folder.Key.LastIndexOf('/') + 1)..];
|
||||
var category = folder.Key.Contains('/') ? folder.Key[..folder.Key.LastIndexOf('/')] : "";
|
||||
var dash = name.IndexOf(" - ", StringComparison.Ordinal);
|
||||
var artist = dash > 0 ? name[..dash] : "";
|
||||
var title = dash > 0 ? name[(dash + 3)..] : name;
|
||||
songs.Add(new CSong(folder.Key, artist, title, category, folder.ToList()));
|
||||
}
|
||||
|
||||
_Catalog = songs;
|
||||
_CatalogTime = DateTime.UtcNow;
|
||||
return songs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string _RawUrl(string path)
|
||||
{
|
||||
return $"https://raw.githubusercontent.com/{_Repo}/{_Branch}/" + string.Join('/', path.Split('/').Select(Uri.EscapeDataString));
|
||||
}
|
||||
|
||||
public async Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var normalized = CSourceHelper.Normalize(query);
|
||||
var catalog = await _GetCatalogAsync(ct);
|
||||
return catalog.Where(s => CSourceHelper.Matches(normalized, s.Artist + " " + s.Title))
|
||||
.Select(s =>
|
||||
{
|
||||
var cover = s.Files.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f.Path).Equals("cover", StringComparison.OrdinalIgnoreCase)).Path;
|
||||
return new SearchResultDto
|
||||
{
|
||||
Source = Id,
|
||||
SourceId = s.Folder,
|
||||
Artist = s.Artist,
|
||||
Title = s.Title,
|
||||
Details = $"{s.License} · {s.Files.Sum(f => f.Size) / 1_000_000.0:0.#} MB",
|
||||
CoverUrl = cover != null ? _RawUrl(cover) : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct)
|
||||
{
|
||||
var song = (await _GetCatalogAsync(ct)).FirstOrDefault(s => s.Folder == sourceId) ?? throw new InvalidOperationException("Song not found in " + _Repo);
|
||||
var total = Math.Max(1, song.Files.Sum(f => f.Size));
|
||||
long done = 0;
|
||||
foreach (var file in song.Files)
|
||||
{
|
||||
var target = Path.Combine(folder, CSourceHelper.SafeName(Path.GetFileName(file.Path)));
|
||||
using var response = await _Http.GetAsync(_RawUrl(file.Path), HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var source = await response.Content.ReadAsStreamAsync(ct);
|
||||
await using var stream = File.Create(target);
|
||||
await CSourceHelper.CopyAsync(source, stream, n =>
|
||||
{
|
||||
done += n;
|
||||
progress.Report((double)done / total);
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VocaluxeSongServer.Contracts;
|
||||
using VocaluxeSongServer.Library;
|
||||
|
||||
namespace VocaluxeSongServer.Sources
|
||||
{
|
||||
/// <summary>Searches all enabled sources at once.</summary>
|
||||
public sealed class CSongSearch
|
||||
{
|
||||
private readonly IReadOnlyList<ISongSource> _Sources;
|
||||
private readonly CLibraryIndex _Index;
|
||||
private readonly ILogger<CSongSearch> _Logger;
|
||||
|
||||
public CSongSearch(IEnumerable<ISongSource> sources, CLibraryIndex index, ILogger<CSongSearch> logger)
|
||||
{
|
||||
_Sources = sources.ToList();
|
||||
_Index = index;
|
||||
_Logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ISongSource> Sources => _Sources;
|
||||
|
||||
public ISongSource? GetSource(string id)
|
||||
{
|
||||
return _Sources.FirstOrDefault(s => s.Id == id);
|
||||
}
|
||||
|
||||
public static string SongKey(string artist, string title)
|
||||
{
|
||||
return CSourceHelper.Normalize(artist) + "|" + CSourceHelper.Normalize(title);
|
||||
}
|
||||
|
||||
public async Task<SearchResponse> SearchAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var response = new SearchResponse();
|
||||
if (CSourceHelper.Normalize(query).Length < 2)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var inLibrary = new HashSet<string>((_Index.Current?.Dto.Songs ?? new List<SongDto>()).Select(s => SongKey(s.Artist, s.Title)));
|
||||
var tasks = _Sources.Where(s => s.Enabled).Select(async source =>
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
return (source, results: await source.SearchAsync(query, timeout.Token), error: (string?)null);
|
||||
}
|
||||
catch (Exception e) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_Logger.LogWarning("Search in {Source} failed: {Message}", source.Id, e.Message);
|
||||
return (source, results: new List<SearchResultDto>(), error: e is OperationCanceledException ? "timeout" : e.Message);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
foreach (var (source, results, error) in await Task.WhenAll(tasks))
|
||||
{
|
||||
if (error != null)
|
||||
{
|
||||
response.Errors[source.Id] = error;
|
||||
}
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
r.InLibrary = inLibrary.Contains(SongKey(r.Artist, r.Title));
|
||||
response.Results.Add(r);
|
||||
}
|
||||
}
|
||||
|
||||
// Best matches first: artist/title starting with the query, then alphabetically.
|
||||
var q = CSourceHelper.Normalize(query);
|
||||
response.Results = response.Results
|
||||
.OrderByDescending(r => CSourceHelper.Normalize(r.Artist + " " + r.Title).StartsWith(q) || CSourceHelper.Normalize(r.Title).StartsWith(q))
|
||||
.ThenBy(r => r.Artist, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(r => r.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs downloads one after another in the background. A song is downloaded into a temporary folder and moved to
|
||||
/// "<library>/<DownloadFolder>/<source>/<Artist - Title>" when complete; then the library is rescanned.
|
||||
/// </summary>
|
||||
public sealed class CDownloadQueue : BackgroundService
|
||||
{
|
||||
private sealed class CJob
|
||||
{
|
||||
public required DownloadJobDto Dto;
|
||||
public required ISongSource Source;
|
||||
}
|
||||
|
||||
private readonly Channel<CJob> _Queue = Channel.CreateUnbounded<CJob>();
|
||||
private readonly ConcurrentDictionary<string, CJob> _Jobs = new();
|
||||
private readonly CLibraryIndex _Index;
|
||||
private readonly CSongSearch _Search;
|
||||
private readonly CLibraryOptions _Options;
|
||||
private readonly ILogger<CDownloadQueue> _Logger;
|
||||
|
||||
public CDownloadQueue(CLibraryIndex index, CSongSearch search, IOptions<CLibraryOptions> options, ILogger<CDownloadQueue> logger)
|
||||
{
|
||||
_Index = index;
|
||||
_Search = search;
|
||||
_Options = options.Value;
|
||||
_Logger = logger;
|
||||
}
|
||||
|
||||
public IEnumerable<DownloadJobDto> Jobs => _Jobs.Values.Select(j => j.Dto).OrderByDescending(j => j.Created);
|
||||
|
||||
/// <summary>Queues a download. Returns the existing job if the same song is already queued or running.</summary>
|
||||
public DownloadJobDto Enqueue(DownloadRequest request)
|
||||
{
|
||||
var source = _Search.GetSource(request.Source);
|
||||
if (source == null || !source.Enabled)
|
||||
{
|
||||
throw new ArgumentException("Unknown or disabled source: " + request.Source);
|
||||
}
|
||||
|
||||
var existing = _Jobs.Values.FirstOrDefault(j => j.Dto.Source == request.Source && j.Dto.SourceId == request.SourceId && j.Dto.State is "queued" or "running");
|
||||
if (existing != null)
|
||||
{
|
||||
return existing.Dto;
|
||||
}
|
||||
|
||||
var job = new CJob
|
||||
{
|
||||
Source = source,
|
||||
Dto = new DownloadJobDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N")[..12],
|
||||
Source = request.Source,
|
||||
SourceId = request.SourceId,
|
||||
Artist = request.Artist,
|
||||
Title = request.Title,
|
||||
State = "queued",
|
||||
Created = DateTimeOffset.UtcNow.ToString("O")
|
||||
}
|
||||
};
|
||||
_Jobs[job.Dto.Id] = job;
|
||||
_Queue.Writer.TryWrite(job);
|
||||
|
||||
// Keep the list short: forget old finished jobs.
|
||||
foreach (var old in _Jobs.Values.Where(j => j.Dto.State is "done" or "failed").OrderByDescending(j => j.Dto.Created).Skip(50).ToList())
|
||||
{
|
||||
_Jobs.TryRemove(old.Dto.Id, out _);
|
||||
}
|
||||
|
||||
return job.Dto;
|
||||
}
|
||||
|
||||
public string DownloadRoot => Path.Combine(_Index.Root, _Options.DownloadFolder);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var job in _Queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
await _RunAsync(job, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _RunAsync(CJob job, CancellationToken ct)
|
||||
{
|
||||
var dto = job.Dto;
|
||||
dto.State = "running";
|
||||
var name = CSourceHelper.SafeName(dto.Artist.Length > 0 ? dto.Artist + " - " + dto.Title : dto.Title);
|
||||
if (name.Length == 0)
|
||||
{
|
||||
name = dto.Source + "-" + CSourceHelper.SafeName(dto.SourceId);
|
||||
}
|
||||
|
||||
var target = Path.Combine(DownloadRoot, dto.Source, name);
|
||||
var temp = Path.Combine(DownloadRoot, ".incoming", dto.Id);
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(target))
|
||||
{
|
||||
dto.State = "done";
|
||||
dto.Progress = 1;
|
||||
dto.Message = "Already downloaded";
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(temp);
|
||||
await job.Source.DownloadAsync(dto.SourceId, temp, new Progress<double>(p => dto.Progress = Math.Clamp(p, 0, 1)), ct);
|
||||
if (!Directory.EnumerateFiles(temp, "*.txt").Any(f => !Path.GetFileName(f).Equals("license.txt", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new InvalidOperationException("The download contains no song file");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
Directory.Move(temp, target);
|
||||
dto.State = "done";
|
||||
dto.Progress = 1;
|
||||
dto.Message = "";
|
||||
_Logger.LogInformation("Downloaded {Artist} - {Title} from {Source}", dto.Artist, dto.Title, dto.Source);
|
||||
_Index.RequestRescan();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
dto.State = "failed";
|
||||
dto.Message = e is OperationCanceledException ? "cancelled" : e.Message;
|
||||
_Logger.LogWarning("Download of {Artist} - {Title} from {Source} failed: {Message}", dto.Artist, dto.Title, dto.Source, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(temp))
|
||||
{
|
||||
Directory.Delete(temp, true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// left for the next start
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using VocaluxeSongServer.Contracts;
|
||||
|
||||
namespace VocaluxeSongServer.Sources
|
||||
{
|
||||
public sealed class CUsdbOptions
|
||||
{
|
||||
public string User { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// USDB (usdb.animux.de), the largest UltraStar database. It has only the song text files and needs a (free)
|
||||
/// account. Audio, video and images come from the sources the song references: the #VIDEO tag
|
||||
/// ("v=<YouTube>,a=<YouTube>,co=<fanart.tv>,bg=...", the format of usdb_syncer) or YouTube links in the
|
||||
/// comments. Media are downloaded with yt-dlp.
|
||||
/// </summary>
|
||||
public sealed partial class CUsdbSongSource : ISongSource
|
||||
{
|
||||
public const string BaseUrl = "https://usdb.animux.de/";
|
||||
private const string _NotLoggedIn = "You are not logged in";
|
||||
private const string _LoginInvalid = "Login or Password invalid";
|
||||
|
||||
private readonly HttpClient _Http;
|
||||
private readonly CUsdbOptions _Options;
|
||||
private readonly CYtDlp _YtDlp;
|
||||
private readonly ILogger<CUsdbSongSource> _Logger;
|
||||
private readonly SemaphoreSlim _LoginLock = new(1, 1);
|
||||
private bool _LoggedIn;
|
||||
|
||||
/// <param name="http">A client with its own cookie container (the USDB session is a cookie).</param>
|
||||
public CUsdbSongSource(HttpClient http, CUsdbOptions options, CYtDlp ytDlp, ILogger<CUsdbSongSource> logger)
|
||||
{
|
||||
_Http = http;
|
||||
_Options = options;
|
||||
_YtDlp = ytDlp;
|
||||
_Logger = logger;
|
||||
}
|
||||
|
||||
public string Id => "usdb";
|
||||
public string Name => "USDB";
|
||||
public string Description => "usdb.animux.de, the largest UltraStar database (notes and lyrics); audio and video from the YouTube links of the song";
|
||||
|
||||
public bool Enabled => _Options.User.Length > 0 && _Options.Password.Length > 0;
|
||||
|
||||
public string Hint => Enabled
|
||||
? (_YtDlp.Available ? "" : "yt-dlp is not installed, songs can't be downloaded")
|
||||
: "Needs a free account at usdb.animux.de (settings Usdb__User and Usdb__Password)";
|
||||
|
||||
#region HTTP
|
||||
private async Task _LoginAsync(CancellationToken ct)
|
||||
{
|
||||
await _LoginLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_LoggedIn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string> { ["user"] = _Options.User, ["pass"] = _Options.Password, ["login"] = "Login" });
|
||||
using var response = await _Http.PostAsync(BaseUrl, form, ct);
|
||||
var html = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode || html.Contains(_LoginInvalid))
|
||||
{
|
||||
throw new InvalidOperationException("USDB login failed (check user and password)");
|
||||
}
|
||||
|
||||
_LoggedIn = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_LoginLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sends a request (logs in first and again when the session expired) and returns the page.</summary>
|
||||
private async Task<string> _RequestAsync(Func<HttpRequestMessage> createRequest, CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
await _LoginAsync(ct);
|
||||
using var request = createRequest();
|
||||
using var response = await _Http.SendAsync(request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(ct);
|
||||
var html = Encoding.UTF8.GetString(bytes);
|
||||
if (!html.Contains(_NotLoggedIn))
|
||||
{
|
||||
return html;
|
||||
}
|
||||
|
||||
_LoggedIn = false;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("USDB: not logged in after login");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Search
|
||||
[GeneratedRegex(@"<tr class=""list_tr\d""[^>]*data-songid=""(\d+)""[^>]*>(.*?)</tr>", RegexOptions.Singleline)]
|
||||
private static partial Regex _Row();
|
||||
|
||||
[GeneratedRegex(@"<td[^>]*>(.*?)</td>", RegexOptions.Singleline)]
|
||||
private static partial Regex _Cell();
|
||||
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex _Tag();
|
||||
|
||||
private static string _Text(string html)
|
||||
{
|
||||
return WebUtility.HtmlDecode(_Tag().Replace(html, " ")).Trim();
|
||||
}
|
||||
|
||||
/// <summary>Parses the result list (link=list with details=1): id, artist, title and the year if there is one.</summary>
|
||||
public static List<SearchResultDto> ParseList(string html)
|
||||
{
|
||||
var results = new List<SearchResultDto>();
|
||||
foreach (Match row in _Row().Matches(html))
|
||||
{
|
||||
// Cells: sample, cover (no text), artist, title, genre, year, edition, golden notes, language, creator, ...
|
||||
var cells = _Cell().Matches(row.Groups[2].Value).Select(c => _Text(c.Groups[1].Value)).Where(t => t.Length > 0).ToList();
|
||||
if (cells.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = row.Groups[1].Value;
|
||||
var year = cells.Skip(2).FirstOrDefault(c => Regex.IsMatch(c, @"^(19|20)\d\d$"));
|
||||
results.Add(new SearchResultDto
|
||||
{
|
||||
Source = "usdb",
|
||||
SourceId = id,
|
||||
Artist = cells[0],
|
||||
Title = cells[1],
|
||||
Details = "USDB #" + id + (year != null ? " · " + year : ""),
|
||||
CoverUrl = BaseUrl + "data/cover/" + id + ".jpg"
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct)
|
||||
{
|
||||
// USDB searches artist and title separately; a query matches either.
|
||||
async Task<List<SearchResultDto>> Search(string field)
|
||||
{
|
||||
var html = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Post, BaseUrl + "index.php?link=list")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
[field] = query, ["order"] = "rating", ["ud"] = "desc", ["limit"] = "50", ["details"] = "1", ["start"] = "0"
|
||||
})
|
||||
}, ct);
|
||||
return ParseList(html);
|
||||
}
|
||||
|
||||
var byArtist = await Search("interpret");
|
||||
var byTitle = await Search("title");
|
||||
return byArtist.Concat(byTitle).GroupBy(r => r.SourceId).Select(g => g.First()).ToList();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Download
|
||||
[GeneratedRegex(@"<textarea[^>]*>(.*?)</textarea>", RegexOptions.Singleline)]
|
||||
private static partial Regex _TextArea();
|
||||
|
||||
/// <summary>Media hints of a song text file (#VIDEO tag in the usdb_syncer format).</summary>
|
||||
public sealed record CMediaHints(string? Video, string? Audio, string? Cover, string? Background);
|
||||
|
||||
public static CMediaHints ParseVideoTag(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || !value.Contains('='))
|
||||
{
|
||||
return new CMediaHints(null, null, null, null);
|
||||
}
|
||||
|
||||
var pairs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in value.Split(','))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq > 0)
|
||||
{
|
||||
pairs[pair[..eq].Trim()] = pair[(eq + 1)..].Trim().Replace("%2C", ",");
|
||||
}
|
||||
}
|
||||
|
||||
return new CMediaHints(pairs.GetValueOrDefault("v"), pairs.GetValueOrDefault("a"), pairs.GetValueOrDefault("co"), pairs.GetValueOrDefault("bg"));
|
||||
}
|
||||
|
||||
/// <summary>URL of a cover/background hint: full URL, host/path, or a fanart.tv id.</summary>
|
||||
public static string ImageUrl(string source)
|
||||
{
|
||||
if (source.Contains("://"))
|
||||
{
|
||||
return source.Replace("images.fanart.tv", "assets.fanart.tv");
|
||||
}
|
||||
|
||||
return source.Contains('/') ? "https://" + source : "https://assets.fanart.tv/fanart/" + source;
|
||||
}
|
||||
|
||||
/// <summary>Splits a song text file into header tags (in order) and the rest (notes).</summary>
|
||||
public static (List<(string Tag, string Value)> Header, string Body) SplitSong(string text)
|
||||
{
|
||||
var header = new List<(string, string)>();
|
||||
var lines = text.Replace("\r\n", "\n").Split('\n');
|
||||
var i = 0;
|
||||
for (; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] != '#')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var colon = line.IndexOf(':');
|
||||
header.Add(colon > 0 ? (line[1..colon].Trim().ToUpperInvariant(), line[(colon + 1)..].Trim()) : (line[1..].Trim().ToUpperInvariant(), ""));
|
||||
}
|
||||
|
||||
return (header, string.Join("\n", lines.Skip(i)));
|
||||
}
|
||||
|
||||
/// <summary>The song text with the media tags replaced by the downloaded files (null = tag removed).</summary>
|
||||
public static string RewriteSong(string text, IReadOnlyDictionary<string, string?> media)
|
||||
{
|
||||
var (header, body) = SplitSong(text);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (tag, value) in header)
|
||||
{
|
||||
if (tag is "MP3" or "AUDIO" or "VIDEO" or "COVER" or "BACKGROUND" or "ENCODING")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append('#').Append(tag).Append(':').Append(value).Append('\n');
|
||||
}
|
||||
|
||||
foreach (var (tag, value) in media)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
sb.Append('#').Append(tag).Append(':').Append(value).Append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.Append(body.TrimEnd('\n')).Append('\n').ToString();
|
||||
}
|
||||
|
||||
public async Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct)
|
||||
{
|
||||
if (!int.TryParse(sourceId, out var id))
|
||||
{
|
||||
throw new ArgumentException("Invalid USDB id " + sourceId);
|
||||
}
|
||||
|
||||
if (!_YtDlp.Available)
|
||||
{
|
||||
throw new InvalidOperationException("yt-dlp is not installed, USDB songs can't be downloaded");
|
||||
}
|
||||
|
||||
var page = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Post, BaseUrl + "index.php?link=gettxt&id=" + id)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["wd"] = "1" })
|
||||
}, ct);
|
||||
var area = _TextArea().Match(page);
|
||||
if (!area.Success)
|
||||
{
|
||||
throw new InvalidOperationException("USDB returned no song text for #" + id);
|
||||
}
|
||||
|
||||
var text = WebUtility.HtmlDecode(area.Groups[1].Value);
|
||||
var (header, _) = SplitSong(text);
|
||||
var hints = ParseVideoTag(header.FirstOrDefault(h => h.Tag == "VIDEO").Value);
|
||||
var artist = header.FirstOrDefault(h => h.Tag == "ARTIST").Value ?? "";
|
||||
var title = header.FirstOrDefault(h => h.Tag == "TITLE").Value ?? "";
|
||||
progress.Report(0.02);
|
||||
|
||||
// Media: the #VIDEO hints, otherwise the YouTube links in the comments of the song page.
|
||||
var videoId = hints.Video != null ? CYtDlp.YouTubeId(hints.Video) : null;
|
||||
var audioId = hints.Audio != null ? CYtDlp.YouTubeId(hints.Audio) : null;
|
||||
if (videoId == null && audioId == null)
|
||||
{
|
||||
var detail = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Get, BaseUrl + "index.php?link=detail&id=" + id), ct);
|
||||
videoId = CYtDlp.FindYouTubeIds(detail).FirstOrDefault();
|
||||
}
|
||||
|
||||
audioId ??= videoId;
|
||||
if (audioId == null)
|
||||
{
|
||||
throw new InvalidOperationException("No audio source known for this song (no YouTube link in the song or its comments)");
|
||||
}
|
||||
|
||||
var media = new Dictionary<string, string?>();
|
||||
media["MP3"] = await _YtDlp.DownloadAsync("https://www.youtube.com/watch?v=" + audioId, folder, "audio",
|
||||
"bestaudio[ext=m4a]/bestaudio", new CScaledProgress(progress, 0.05, 0.45), ct);
|
||||
if (videoId != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
media["VIDEO"] = await _YtDlp.DownloadAsync("https://www.youtube.com/watch?v=" + videoId, folder, "video",
|
||||
"bestvideo[height<=720][ext=mp4]/bestvideo[height<=720]/best[height<=720]", new CScaledProgress(progress, 0.45, 0.9), ct);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_Logger.LogWarning("USDB #{Id}: video download failed, the song is saved without video: {Message}", id, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
media["COVER"] = await _DownloadImageAsync(hints.Cover != null ? ImageUrl(hints.Cover) : (videoId ?? audioId) is { } yt ? $"https://i.ytimg.com/vi/{yt}/hqdefault.jpg" : null,
|
||||
folder, "cover", ct);
|
||||
media["BACKGROUND"] = await _DownloadImageAsync(hints.Background != null ? ImageUrl(hints.Background) : null, folder, "background", ct);
|
||||
|
||||
var name = CSourceHelper.SafeName(artist + " - " + title);
|
||||
await File.WriteAllTextAsync(Path.Combine(folder, name + ".txt"), RewriteSong(text, media), new UTF8Encoding(true), ct);
|
||||
progress.Report(1);
|
||||
}
|
||||
|
||||
private async Task<string?> _DownloadImageAsync(string? url, string folder, string baseName, CancellationToken ct)
|
||||
{
|
||||
if (url == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _Http.GetAsync(url, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var ext = response.Content.Headers.ContentType?.MediaType == "image/png" ? ".png" : ".jpg";
|
||||
await File.WriteAllBytesAsync(Path.Combine(folder, baseName + ext), await response.Content.ReadAsByteArrayAsync(ct), ct);
|
||||
return baseName + ext;
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
_Logger.LogInformation("Image {Url} not downloaded: {Message}", url, e.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>Maps the progress of a part of a job (0..1) to a range of the whole job.</summary>
|
||||
public sealed class CScaledProgress : IProgress<double>
|
||||
{
|
||||
private readonly IProgress<double> _Target;
|
||||
private readonly double _From;
|
||||
private readonly double _To;
|
||||
|
||||
public CScaledProgress(IProgress<double> target, double from, double to)
|
||||
{
|
||||
_Target = target;
|
||||
_From = from;
|
||||
_To = to;
|
||||
}
|
||||
|
||||
public void Report(double value)
|
||||
{
|
||||
_Target.Report(_From + (_To - _From) * Math.Clamp(value, 0, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VocaluxeSongServer.Sources
|
||||
{
|
||||
/// <summary>Downloads audio/video with yt-dlp (installed in the Docker image; ffmpeg is needed for merging).</summary>
|
||||
public sealed partial class CYtDlp
|
||||
{
|
||||
private readonly string _Executable;
|
||||
private readonly ILogger<CYtDlp> _Logger;
|
||||
|
||||
public CYtDlp(IConfiguration config, ILogger<CYtDlp> logger)
|
||||
{
|
||||
_Executable = config["YtDlp:Path"] is { Length: > 0 } path ? path : "yt-dlp";
|
||||
_Logger = logger;
|
||||
}
|
||||
|
||||
private bool? _Available;
|
||||
|
||||
public bool Available
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_Available == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(new ProcessStartInfo(_Executable, "--version") { RedirectStandardOutput = true, RedirectStandardError = true });
|
||||
p!.WaitForExit(15000);
|
||||
_Available = p.ExitCode == 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_Available = false;
|
||||
}
|
||||
}
|
||||
|
||||
return _Available.Value;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{11})")]
|
||||
private static partial Regex _YouTubeUrl();
|
||||
|
||||
[GeneratedRegex(@"^[A-Za-z0-9_-]{11}$")]
|
||||
private static partial Regex _YouTubeIdOnly();
|
||||
|
||||
[GeneratedRegex(@"\[download\]\s+([\d.]+)%")]
|
||||
private static partial Regex _ProgressLine();
|
||||
|
||||
/// <summary>A YouTube id from an id or URL, or null.</summary>
|
||||
public static string? YouTubeId(string value)
|
||||
{
|
||||
value = value.Trim();
|
||||
if (_YouTubeIdOnly().IsMatch(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var m = _YouTubeUrl().Match(value);
|
||||
return m.Success ? m.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
/// <summary>All YouTube ids in a text (e.g. HTML of comments), in order, without duplicates.</summary>
|
||||
public static List<string> FindYouTubeIds(string text)
|
||||
{
|
||||
return _YouTubeUrl().Matches(text).Select(m => m.Groups[1].Value).Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads into <paramref name="folder" /> as "<paramref name="baseName" />.ext" and returns the file name.
|
||||
/// <paramref name="format" /> is a yt-dlp format selector.
|
||||
/// </summary>
|
||||
public async Task<string> DownloadAsync(string url, string folder, string baseName, string format, IProgress<double>? progress, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo(_Executable)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
WorkingDirectory = folder
|
||||
};
|
||||
foreach (var arg in new[] { "--no-playlist", "--newline", "--no-mtime", "-f", format, "-o", baseName + ".%(ext)s", "--", url })
|
||||
{
|
||||
psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
using var process = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp could not be started");
|
||||
var errors = new List<string>();
|
||||
var stderr = Task.Run(async () =>
|
||||
{
|
||||
while (await process.StandardError.ReadLineAsync(ct) is { } line)
|
||||
{
|
||||
errors.Add(line);
|
||||
}
|
||||
}, ct);
|
||||
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
|
||||
{
|
||||
var m = _ProgressLine().Match(line);
|
||||
if (m.Success && double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
|
||||
{
|
||||
progress?.Report(percent / 100);
|
||||
}
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(ct);
|
||||
await stderr;
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = errors.LastOrDefault(e => e.Contains("ERROR")) ?? errors.LastOrDefault() ?? "exit code " + process.ExitCode;
|
||||
_Logger.LogWarning("yt-dlp failed for {Url}: {Error}", url, error);
|
||||
throw new InvalidOperationException("yt-dlp: " + error);
|
||||
}
|
||||
|
||||
var file = Directory.EnumerateFiles(folder, baseName + ".*").Select(Path.GetFileName).FirstOrDefault(f => !f!.EndsWith(".part"));
|
||||
return file ?? throw new InvalidOperationException("yt-dlp finished but no file was written");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using VocaluxeSongServer.Contracts;
|
||||
|
||||
namespace VocaluxeSongServer.Sources
|
||||
{
|
||||
/// <summary>A place where new songs can be found and downloaded (song database, song repository, ...).</summary>
|
||||
public interface ISongSource
|
||||
{
|
||||
string Id { get; }
|
||||
string Name { get; }
|
||||
string Description { get; }
|
||||
bool Enabled { get; }
|
||||
/// <summary>Why the source is disabled, or empty.</summary>
|
||||
string Hint { get; }
|
||||
|
||||
Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the song into <paramref name="folder" /> (created by the caller, empty). Must leave a playable
|
||||
/// UltraStar song there (text file + audio) or throw.
|
||||
/// </summary>
|
||||
Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct);
|
||||
}
|
||||
|
||||
public static class CSourceHelper
|
||||
{
|
||||
/// <summary>Lower case, without diacritics and punctuation, single spaces: for matching artists and titles.</summary>
|
||||
public static string Normalize(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in s.Normalize(NormalizationForm.FormD))
|
||||
{
|
||||
var cat = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (cat == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : ' ');
|
||||
}
|
||||
|
||||
return string.Join(' ', sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
/// <summary>True if every word of the query occurs in the text (both normalized).</summary>
|
||||
public static bool Matches(string normalizedQuery, string text)
|
||||
{
|
||||
var normalized = " " + Normalize(text) + " ";
|
||||
return normalizedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries).All(w => normalized.Contains(w));
|
||||
}
|
||||
|
||||
/// <summary>A file or folder name without characters that are invalid on Windows or Linux.</summary>
|
||||
public static string SafeName(string name)
|
||||
{
|
||||
var invalid = "<>:\"/\\|?*".ToCharArray();
|
||||
var cleaned = new string(name.Select(c => invalid.Contains(c) || char.IsControl(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
|
||||
return cleaned.Length > 120 ? cleaned[..120] : cleaned;
|
||||
}
|
||||
|
||||
/// <summary>Copies a stream and reports the bytes copied.</summary>
|
||||
public static async Task CopyAsync(Stream source, Stream target, Action<long> onBytes, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
onBytes(read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,19 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"Urls": "http://0.0.0.0:8080",
|
||||
"Library": {
|
||||
"Path": "/songs",
|
||||
"ScanIntervalMinutes": 10,
|
||||
"ApiKey": ""
|
||||
"ApiKey": "",
|
||||
"DownloadFolder": "Downloads"
|
||||
},
|
||||
"Usdb": {
|
||||
"User": "",
|
||||
"Password": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
:root {
|
||||
--bg: #f6f5fb;
|
||||
--card: #ffffff;
|
||||
--text: #1c1830;
|
||||
--muted: #6b6785;
|
||||
--line: #e4e1ef;
|
||||
--accent: #7c3aed;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #ede7fd;
|
||||
--ok: #15803d;
|
||||
--ok-soft: #dcfce7;
|
||||
--err: #b91c1c;
|
||||
--err-soft: #fee2e2;
|
||||
--radius: 14px;
|
||||
--nav-h: 64px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #12101c;
|
||||
--card: #1d1a2b;
|
||||
--text: #eceaf6;
|
||||
--muted: #a29dbd;
|
||||
--line: #2e2a42;
|
||||
--accent: #a78bfa;
|
||||
--accent-text: #160f2c;
|
||||
--accent-soft: #2c2350;
|
||||
--ok: #4ade80;
|
||||
--ok-soft: #14301f;
|
||||
--err: #f87171;
|
||||
--err-soft: #3a1717;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { margin: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
padding-bottom: calc(var(--nav-h) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: calc(10px + env(safe-area-inset-top)) 16px 10px;
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.top h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.tab { display: none; }
|
||||
.tab.active { display: block; }
|
||||
|
||||
.muted { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
.searchbar { margin: 4px 0 10px; }
|
||||
|
||||
.searchbar input, dialog input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font-size: 1.05rem; /* >= 16px: no zoom on iOS */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchbar input:focus, dialog input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.chip.off { background: transparent; border: 1px dashed var(--line); color: var(--muted); }
|
||||
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
|
||||
.chip.off .dot { background: var(--muted); }
|
||||
|
||||
.status { padding: 12px 4px; color: var(--muted); }
|
||||
.status.error { color: var(--err); }
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.item .cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: var(--accent-soft);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item .text { min-width: 0; }
|
||||
|
||||
.item .title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item .artist, .item .details {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item .artist { font-size: .92rem; }
|
||||
.item .details { color: var(--muted); font-size: .8rem; }
|
||||
|
||||
.source {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action:disabled { cursor: default; }
|
||||
.action.done { background: var(--ok-soft); color: var(--ok); }
|
||||
.action.pending { background: var(--accent-soft); color: var(--text); }
|
||||
|
||||
.progress {
|
||||
grid-column: 1 / -1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width .4s;
|
||||
}
|
||||
|
||||
.state { font-size: .8rem; font-weight: 600; }
|
||||
.state.done { color: var(--ok); }
|
||||
.state.failed { color: var(--err); }
|
||||
.message { grid-column: 1 / -1; color: var(--err); font-size: .85rem; overflow-wrap: anywhere; }
|
||||
|
||||
.compact .item { grid-template-columns: 1fr; padding: 10px 14px; }
|
||||
|
||||
.tabs {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
height: calc(var(--nav-h) + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
}
|
||||
|
||||
.tabs button.active { color: var(--accent); font-weight: 600; }
|
||||
|
||||
.tabs svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: calc(50% + 6px);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: .7rem;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: min(92vw, 400px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
dialog h2 { margin: 0 0 4px; font-size: 1.1rem; }
|
||||
dialog menu { display: flex; justify-content: flex-end; margin: 12px 0 0; padding: 0; }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .4); }
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.tabs {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 720px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
}
|
||||
|
||||
.tabs button { flex-direction: row; gap: 8px; font-size: .95rem; }
|
||||
.badge { position: static; }
|
||||
.top { max-width: 752px; margin: 0 auto; }
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use strict";
|
||||
// Web UI of the Vocaluxe song server: search sources, queue downloads, browse the library.
|
||||
// Plain JavaScript, no build step. API: /api/v1 (see README.md).
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const state = {
|
||||
tab: "search",
|
||||
library: null, // LibraryDto
|
||||
libraryVersion: "",
|
||||
jobs: [], // DownloadJobDto[]
|
||||
results: [],
|
||||
searchSeq: 0,
|
||||
};
|
||||
|
||||
function storageGet(key) {
|
||||
try { return localStorage.getItem(key) || ""; } catch { return ""; }
|
||||
}
|
||||
|
||||
function storageSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// ---- API ------------------------------------------------------------------
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const key = storageGet("apiKey");
|
||||
if (key) headers["X-Api-Key"] = key;
|
||||
if (options.body) headers["Content-Type"] = "application/json";
|
||||
const response = await fetch("api/v1/" + path, { ...options, headers });
|
||||
if (response.status === 401) {
|
||||
await askForKey();
|
||||
return api(path, options);
|
||||
}
|
||||
if (!response.ok) {
|
||||
let title = response.statusText;
|
||||
try { title = (await response.json()).title || title; } catch { /* not JSON */ }
|
||||
throw new Error(title);
|
||||
}
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
function askForKey() {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = $("#keyDialog");
|
||||
dialog.addEventListener("close", () => {
|
||||
storageSet("apiKey", $("#keyInput").value.trim());
|
||||
resolve();
|
||||
}, { once: true });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Rendering helpers ----------------------------------------------------
|
||||
|
||||
function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (v !== false && v != null) node.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child != null && child !== false) node.append(child);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function cover(url, title) {
|
||||
const fallback = el("div", { class: "cover", "aria-hidden": "true" }, (title || "?").trim().charAt(0).toUpperCase());
|
||||
if (!url) return fallback;
|
||||
const img = el("img", { class: "cover", src: url, alt: "", loading: "lazy", referrerpolicy: "no-referrer" });
|
||||
img.addEventListener("error", () => img.replaceWith(fallback), { once: true });
|
||||
return img;
|
||||
}
|
||||
|
||||
function songKey(artist, title) {
|
||||
const norm = (s) => (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
||||
return norm(artist) + "|" + norm(title);
|
||||
}
|
||||
|
||||
// ---- Tabs -----------------------------------------------------------------
|
||||
|
||||
function showTab(tab) {
|
||||
state.tab = tab;
|
||||
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.id === "tab-" + tab));
|
||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
storageSet("tab", tab);
|
||||
if (tab === "library") loadLibrary();
|
||||
if (tab === "downloads") renderJobs();
|
||||
if (tab === "search") $("#query").focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tabs button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||||
|
||||
// ---- Sources --------------------------------------------------------------
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
const sources = await api("sources");
|
||||
$("#sources").replaceChildren(...sources.map((s) =>
|
||||
el("span", { class: "chip" + (s.enabled && !s.hint ? "" : " off"), title: s.hint || s.description },
|
||||
el("span", { class: "dot" }), s.name, s.hint && s.enabled ? " (limited)" : "", !s.enabled ? " (off)" : "")));
|
||||
} catch (e) {
|
||||
$("#sources").replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Search ---------------------------------------------------------------
|
||||
|
||||
let searchTimer = 0;
|
||||
|
||||
$("#query").addEventListener("input", () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(search, 450);
|
||||
});
|
||||
|
||||
$("#searchForm").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(searchTimer);
|
||||
$("#query").blur(); // hide the keyboard on phones
|
||||
search();
|
||||
});
|
||||
|
||||
async function search() {
|
||||
const q = $("#query").value.trim();
|
||||
const seq = ++state.searchSeq;
|
||||
const status = $("#searchStatus");
|
||||
status.className = "status";
|
||||
if (q.length < 2) {
|
||||
state.results = [];
|
||||
status.textContent = "Type at least two letters. All song sources are searched at once.";
|
||||
renderResults();
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = "Searching…";
|
||||
try {
|
||||
const response = await api("search?q=" + encodeURIComponent(q));
|
||||
if (seq !== state.searchSeq) return; // a newer search is running
|
||||
state.results = response.results;
|
||||
const errors = Object.entries(response.errors || {}).map(([source, msg]) => `${source}: ${msg}`);
|
||||
status.textContent = (response.results.length ? `${response.results.length} songs found` : "Nothing found") +
|
||||
(errors.length ? ` · not searched: ${errors.join("; ")}` : "");
|
||||
status.classList.toggle("error", errors.length > 0 && response.results.length === 0);
|
||||
renderResults();
|
||||
} catch (e) {
|
||||
if (seq !== state.searchSeq) return;
|
||||
status.textContent = "Search failed: " + e.message;
|
||||
status.classList.add("error");
|
||||
}
|
||||
}
|
||||
|
||||
function jobFor(result) {
|
||||
return state.jobs.find((j) => j.source === result.source && j.sourceId === result.sourceId);
|
||||
}
|
||||
|
||||
function resultButton(result) {
|
||||
const job = jobFor(result);
|
||||
if (result.inLibrary || (job && job.state === "done")) {
|
||||
return el("button", { class: "action done", disabled: true, "aria-label": "In the library" }, "✓");
|
||||
}
|
||||
if (job && (job.state === "queued" || job.state === "running")) {
|
||||
return el("button", { class: "action pending", disabled: true }, Math.round(job.progress * 100) + "%");
|
||||
}
|
||||
return el("button", {
|
||||
class: "action",
|
||||
"aria-label": `Download ${result.artist} – ${result.title}`,
|
||||
onclick: (e) => download(result, e.currentTarget),
|
||||
}, job && job.state === "failed" ? "Retry" : "Get");
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
$("#results").replaceChildren(...state.results.map((r) =>
|
||||
el("li", { class: "item", "data-key": r.source + "/" + r.sourceId },
|
||||
cover(r.coverUrl, r.title),
|
||||
el("div", { class: "text" },
|
||||
el("div", { class: "title" }, r.title),
|
||||
el("div", { class: "artist" }, r.artist),
|
||||
el("div", { class: "details" }, el("span", { class: "source" }, r.source), r.details)),
|
||||
resultButton(r))));
|
||||
}
|
||||
|
||||
async function download(result, button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "…";
|
||||
try {
|
||||
const job = await api("downloads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: result.source, sourceId: result.sourceId, artist: result.artist, title: result.title }),
|
||||
});
|
||||
state.jobs = [job, ...state.jobs.filter((j) => j.id !== job.id)];
|
||||
renderResults();
|
||||
renderBadge();
|
||||
pollJobs(true);
|
||||
} catch (e) {
|
||||
button.disabled = false;
|
||||
button.textContent = "Get";
|
||||
alert("Download failed: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Downloads ------------------------------------------------------------
|
||||
|
||||
let pollTimer = 0;
|
||||
|
||||
async function pollJobs(soon) {
|
||||
clearTimeout(pollTimer);
|
||||
if (soon) {
|
||||
pollTimer = setTimeout(() => pollJobs(false), 800);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const before = state.jobs.filter((j) => j.state === "done").length;
|
||||
state.jobs = await api("downloads");
|
||||
renderJobs();
|
||||
renderBadge();
|
||||
if (state.results.length) renderResults();
|
||||
if (state.jobs.filter((j) => j.state === "done").length !== before) {
|
||||
state.libraryVersion = ""; // a song was added: reload the library when it's shown
|
||||
setTimeout(loadLibraryCount, 3000);
|
||||
}
|
||||
} catch { /* try again later */ }
|
||||
const active = state.jobs.some((j) => j.state === "queued" || j.state === "running");
|
||||
pollTimer = setTimeout(() => pollJobs(false), active ? 1500 : 10000);
|
||||
}
|
||||
|
||||
function renderBadge() {
|
||||
const active = state.jobs.filter((j) => j.state === "queued" || j.state === "running").length;
|
||||
const badge = $("#activeBadge");
|
||||
badge.hidden = active === 0;
|
||||
badge.textContent = active;
|
||||
}
|
||||
|
||||
const stateText = { queued: "Waiting", running: "Downloading", done: "Done", failed: "Failed" };
|
||||
|
||||
function renderJobs() {
|
||||
$("#downloadsEmpty").hidden = state.jobs.length > 0;
|
||||
$("#downloads").replaceChildren(...state.jobs.map((j) =>
|
||||
el("li", { class: "item" },
|
||||
cover("", j.title),
|
||||
el("div", { class: "text" },
|
||||
el("div", { class: "title" }, j.title),
|
||||
el("div", { class: "artist" }, j.artist),
|
||||
el("div", { class: "details" }, el("span", { class: "source" }, j.source),
|
||||
j.message && j.state === "done" ? j.message : "")),
|
||||
el("span", { class: "state " + j.state }, stateText[j.state] || j.state),
|
||||
j.state === "running" || j.state === "queued"
|
||||
? el("div", { class: "progress", role: "progressbar", "aria-valuenow": Math.round(j.progress * 100), "aria-valuemin": 0, "aria-valuemax": 100 },
|
||||
el("span", { style: `width:${Math.round(j.progress * 100)}%` }))
|
||||
: null,
|
||||
j.state === "failed" ? el("div", { class: "message" }, j.message) : null)));
|
||||
}
|
||||
|
||||
// ---- Library --------------------------------------------------------------
|
||||
|
||||
async function loadLibraryCount() {
|
||||
try {
|
||||
const health = await fetch("health").then((r) => r.json());
|
||||
if (health.songs != null) $("#libraryCount").textContent = health.songs + " songs";
|
||||
} catch { /* offline */ }
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const status = $("#libraryStatus");
|
||||
if (state.library && state.libraryVersion) {
|
||||
renderLibrary();
|
||||
return;
|
||||
}
|
||||
status.textContent = "Loading…";
|
||||
try {
|
||||
state.library = await api("library");
|
||||
state.libraryVersion = state.library.version;
|
||||
$("#libraryCount").textContent = state.library.songs.length + " songs";
|
||||
renderLibrary();
|
||||
} catch (e) {
|
||||
status.textContent = "Library not available: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$("#libraryFilter").addEventListener("input", () => renderLibrary());
|
||||
|
||||
function renderLibrary() {
|
||||
if (!state.library) return;
|
||||
const words = songKey($("#libraryFilter").value, "").replace("|", "").split(" ").filter(Boolean);
|
||||
const songs = state.library.songs
|
||||
.filter((s) => { const k = songKey(s.artist, s.title).replace("|", " "); return words.every((w) => k.includes(w)); })
|
||||
.sort((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title));
|
||||
const shown = songs.slice(0, 300);
|
||||
$("#libraryStatus").textContent = songs.length === 0
|
||||
? (state.library.songs.length ? "No matching songs." : "The library is empty. Download songs under Search.")
|
||||
: songs.length > shown.length ? `${songs.length} songs, showing the first ${shown.length}. Filter to narrow down.` : `${songs.length} songs`;
|
||||
$("#library").replaceChildren(...shown.map((s) =>
|
||||
el("li", { class: "item" },
|
||||
el("div", { class: "text" }, el("div", { class: "title" }, s.title), el("div", { class: "artist" }, s.artist)))));
|
||||
}
|
||||
|
||||
// ---- Start ----------------------------------------------------------------
|
||||
|
||||
// A link like "?q=queen" opens a search, "?tab=library" a tab.
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("q")) $("#query").value = params.get("q");
|
||||
const startTab = params.get("q") ? "search" : params.get("tab") || storageGet("tab");
|
||||
|
||||
loadSources();
|
||||
loadLibraryCount();
|
||||
pollJobs(false);
|
||||
search();
|
||||
showTab(["search", "downloads", "library"].includes(startTab) ? startTab : "search");
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#7c3aed"/><g fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><rect x="24" y="10" width="16" height="26" rx="8"/><path d="M17 30a15 15 0 0 0 30 0M32 45v9M24 54h16"/></g></svg>
|
||||
|
After Width: | Height: | Size: 320 B |
@@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#7c3aed">
|
||||
<title>Karaoke Songs</title>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="icon.svg">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<h1><img src="icon.svg" alt="" width="28" height="28"> Karaoke Songs</h1>
|
||||
<span id="libraryCount" class="muted"></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="tab-search" class="tab active" aria-label="Search">
|
||||
<form id="searchForm" class="searchbar" role="search">
|
||||
<input id="query" type="search" placeholder="Artist or title" autocomplete="off" enterkeyhint="search" aria-label="Search songs">
|
||||
</form>
|
||||
<div id="sources" class="chips"></div>
|
||||
<div id="searchStatus" class="status"></div>
|
||||
<ul id="results" class="list"></ul>
|
||||
</section>
|
||||
|
||||
<section id="tab-downloads" class="tab" aria-label="Downloads">
|
||||
<div id="downloadsEmpty" class="status">No downloads yet. Find songs under Search.</div>
|
||||
<ul id="downloads" class="list"></ul>
|
||||
</section>
|
||||
|
||||
<section id="tab-library" class="tab" aria-label="Library">
|
||||
<form class="searchbar" onsubmit="return false">
|
||||
<input id="libraryFilter" type="search" placeholder="Filter the library" autocomplete="off" aria-label="Filter the library">
|
||||
</form>
|
||||
<div id="libraryStatus" class="status"></div>
|
||||
<ul id="library" class="list compact"></ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="tabs" aria-label="Sections">
|
||||
<button data-tab="search" class="active">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>
|
||||
Search
|
||||
</button>
|
||||
<button data-tab="downloads">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11m0 0-4-4m4 4 4-4M5 20h14"/></svg>
|
||||
Downloads <span id="activeBadge" class="badge" hidden></span>
|
||||
</button>
|
||||
<button data-tab="library">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l11-2v12"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>
|
||||
Library
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog id="keyDialog">
|
||||
<form method="dialog">
|
||||
<h2>Access key</h2>
|
||||
<p class="muted">This song server needs an access key.</p>
|
||||
<input id="keyInput" type="password" autocomplete="current-password" aria-label="Access key">
|
||||
<menu><button value="ok" class="primary">Save</button></menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Karaoke Songs",
|
||||
"short_name": "Karaoke",
|
||||
"start_url": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#12101c",
|
||||
"theme_color": "#7c3aed",
|
||||
"icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
|
||||
}
|
||||
Reference in new issue
Block a user