diff --git a/AGENTS.md b/AGENTS.md index 74932df8..a7755291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/Tests/SongServer/CSongSourcesTest.cs b/Tests/SongServer/CSongSourcesTest.cs new file mode 100644 index 00000000..c3e0b52e --- /dev/null +++ b/Tests/SongServer/CSongSourcesTest.cs @@ -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 . +#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 = @" + + + + + + +
QueenDon't Stop Me NowRock1978SingStarYesEnglishsomeone51000
ABBAWaterloo
"; + 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 x 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 { ["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")); + } + } +} diff --git a/Vocaluxe/Properties/AssemblyInfo.cs b/Vocaluxe/Properties/AssemblyInfo.cs index 8720370a..0921dce3 100644 --- a/Vocaluxe/Properties/AssemblyInfo.cs +++ b/Vocaluxe/Properties/AssemblyInfo.cs @@ -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("")] diff --git a/VocaluxeSongServer/Contracts/LibraryDtos.cs b/VocaluxeSongServer/Contracts/LibraryDtos.cs index 0a3f4cad..2d758c31 100644 --- a/VocaluxeSongServer/Contracts/LibraryDtos.cs +++ b/VocaluxeSongServer/Contracts/LibraryDtos.cs @@ -55,4 +55,62 @@ namespace VocaluxeSongServer.Contracts { public List Paths { get; set; } = new List(); } + + /// GET /api/v1/sources: where new songs can be found. + public sealed class SourceDto + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public bool Enabled { get; set; } + /// Why the source is disabled (e.g. missing account), or empty. + public string Hint { get; set; } = ""; + } + + /// GET /api/v1/search?q=...: results of all enabled sources. + public sealed class SearchResponse + { + public List Results { get; set; } = new List(); + /// Sources that failed for this search: source id -> error message. + public Dictionary Errors { get; set; } = new Dictionary(); + } + + public sealed class SearchResultDto + { + public string Source { get; set; } = ""; + /// Id of the song within its source; used to download it. + public string SourceId { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + /// Extra info shown with the result (language, year, license, ...). + public string Details { get; set; } = ""; + public string CoverUrl { get; set; } = ""; + /// True if a song with the same artist and title is already in the library. + public bool InLibrary { get; set; } + } + + /// POST /api/v1/downloads + public sealed class DownloadRequest + { + public string Source { get; set; } = ""; + public string SourceId { get; set; } = ""; + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + } + + /// GET /api/v1/downloads: queued, running and finished downloads (newest first). + 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; } = ""; + /// queued, running, done or failed + public string State { get; set; } = ""; + /// 0..1 + public double Progress { get; set; } + public string Message { get; set; } = ""; + public string Created { get; set; } = ""; + } } diff --git a/VocaluxeSongServer/Dockerfile b/VocaluxeSongServer/Dockerfile index 0990ab11..5523a0b7 100644 --- a/VocaluxeSongServer/Dockerfile +++ b/VocaluxeSongServer/Dockerfile @@ -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 /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"] diff --git a/VocaluxeSongServer/Library/CLibraryIndex.cs b/VocaluxeSongServer/Library/CLibraryIndex.cs index ce0424a7..db3a001b 100644 --- a/VocaluxeSongServer/Library/CLibraryIndex.cs +++ b/VocaluxeSongServer/Library/CLibraryIndex.cs @@ -8,6 +8,8 @@ namespace VocaluxeSongServer.Library public string Path { get; set; } = "/songs"; /// Minutes between automatic rescans. 0 = only at startup and via POST /api/v1/library/rescan. public int ScanIntervalMinutes { get; set; } = 10; + /// Folder below the library root for downloaded songs (one subfolder per source). + public string DownloadFolder { get; set; } = "Downloads"; /// If set, every /api request needs the header X-Api-Key with this value. public string ApiKey { get; set; } = ""; } diff --git a/VocaluxeSongServer/Program.cs b/VocaluxeSongServer/Program.cs index e561bf30..b058b49b 100644 --- a/VocaluxeSongServer/Program.cs +++ b/VocaluxeSongServer/Program.cs @@ -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(builder.Configuration.GetSection("Li builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Song sources for search and download (see Sources/). USDB needs Usdb__User and Usdb__Password. +builder.Services.AddSingleton(); +builder.Services.AddHttpClient("sources", c => c.Timeout = TimeSpan.FromMinutes(10)); +builder.Services.AddSingleton(sp => new CGitHubSongSource(sp.GetRequiredService().CreateClient("sources"))); +builder.Services.AddSingleton(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() ?? new CUsdbOptions(), + sp.GetRequiredService(), sp.GetRequiredService>())); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); 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 { } diff --git a/VocaluxeSongServer/README.md b/VocaluxeSongServer/README.md index 7299ca80..50e2e4bd 100644 --- a/VocaluxeSongServer/README.md +++ b/VocaluxeSongServer/README.md @@ -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://: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 (`/`) | +| `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/` 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 diff --git a/VocaluxeSongServer/Sources/CGitHubSongSource.cs b/VocaluxeSongServer/Sources/CGitHubSongSource.cs new file mode 100644 index 00000000..2ca20a3c --- /dev/null +++ b/VocaluxeSongServer/Sources/CGitHubSongSource.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using VocaluxeSongServer.Contracts; + +namespace VocaluxeSongServer.Sources +{ + /// + /// 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). + /// + 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? _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> _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(); + 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> 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 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); + } + } + } +} diff --git a/VocaluxeSongServer/Sources/CSongSearch.cs b/VocaluxeSongServer/Sources/CSongSearch.cs new file mode 100644 index 00000000..5f743dad --- /dev/null +++ b/VocaluxeSongServer/Sources/CSongSearch.cs @@ -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 +{ + /// Searches all enabled sources at once. + public sealed class CSongSearch + { + private readonly IReadOnlyList _Sources; + private readonly CLibraryIndex _Index; + private readonly ILogger _Logger; + + public CSongSearch(IEnumerable sources, CLibraryIndex index, ILogger logger) + { + _Sources = sources.ToList(); + _Index = index; + _Logger = logger; + } + + public IReadOnlyList 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 SearchAsync(string query, CancellationToken ct) + { + var response = new SearchResponse(); + if (CSourceHelper.Normalize(query).Length < 2) + { + return response; + } + + var inLibrary = new HashSet((_Index.Current?.Dto.Songs ?? new List()).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(), 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; + } + } + + /// + /// 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. + /// + public sealed class CDownloadQueue : BackgroundService + { + private sealed class CJob + { + public required DownloadJobDto Dto; + public required ISongSource Source; + } + + private readonly Channel _Queue = Channel.CreateUnbounded(); + private readonly ConcurrentDictionary _Jobs = new(); + private readonly CLibraryIndex _Index; + private readonly CSongSearch _Search; + private readonly CLibraryOptions _Options; + private readonly ILogger _Logger; + + public CDownloadQueue(CLibraryIndex index, CSongSearch search, IOptions options, ILogger logger) + { + _Index = index; + _Search = search; + _Options = options.Value; + _Logger = logger; + } + + public IEnumerable Jobs => _Jobs.Values.Select(j => j.Dto).OrderByDescending(j => j.Created); + + /// Queues a download. Returns the existing job if the same song is already queued or running. + 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(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 + } + } + } + } +} diff --git a/VocaluxeSongServer/Sources/CUsdbSongSource.cs b/VocaluxeSongServer/Sources/CUsdbSongSource.cs new file mode 100644 index 00000000..bcf6a77d --- /dev/null +++ b/VocaluxeSongServer/Sources/CUsdbSongSource.cs @@ -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; } = ""; + } + + /// + /// 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. + /// + 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 _Logger; + private readonly SemaphoreSlim _LoginLock = new(1, 1); + private bool _LoggedIn; + + /// A client with its own cookie container (the USDB session is a cookie). + public CUsdbSongSource(HttpClient http, CUsdbOptions options, CYtDlp ytDlp, ILogger 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 { ["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(); + } + } + + /// Sends a request (logs in first and again when the session expired) and returns the page. + private async Task _RequestAsync(Func 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(@"]*data-songid=""(\d+)""[^>]*>(.*?)", RegexOptions.Singleline)] + private static partial Regex _Row(); + + [GeneratedRegex(@"]*>(.*?)", 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(); + } + + /// Parses the result list (link=list with details=1): id, artist, title and the year if there is one. + public static List ParseList(string html) + { + var results = new List(); + 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> SearchAsync(string query, CancellationToken ct) + { + // USDB searches artist and title separately; a query matches either. + async Task> Search(string field) + { + var html = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Post, BaseUrl + "index.php?link=list") + { + Content = new FormUrlEncodedContent(new Dictionary + { + [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(@"]*>(.*?)", RegexOptions.Singleline)] + private static partial Regex _TextArea(); + + /// Media hints of a song text file (#VIDEO tag in the usdb_syncer format). + 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(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")); + } + + /// URL of a cover/background hint: full URL, host/path, or a fanart.tv id. + 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; + } + + /// Splits a song text file into header tags (in order) and the rest (notes). + 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))); + } + + /// The song text with the media tags replaced by the downloaded files (null = tag removed). + public static string RewriteSong(string text, IReadOnlyDictionary 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 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 { ["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(); + 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 _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 + } + + /// Maps the progress of a part of a job (0..1) to a range of the whole job. + public sealed class CScaledProgress : IProgress + { + private readonly IProgress _Target; + private readonly double _From; + private readonly double _To; + + public CScaledProgress(IProgress 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)); + } + } +} diff --git a/VocaluxeSongServer/Sources/CYtDlp.cs b/VocaluxeSongServer/Sources/CYtDlp.cs new file mode 100644 index 00000000..7478a72a --- /dev/null +++ b/VocaluxeSongServer/Sources/CYtDlp.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace VocaluxeSongServer.Sources +{ + /// Downloads audio/video with yt-dlp (installed in the Docker image; ffmpeg is needed for merging). + public sealed partial class CYtDlp + { + private readonly string _Executable; + private readonly ILogger _Logger; + + public CYtDlp(IConfiguration config, ILogger 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(); + + /// A YouTube id from an id or URL, or null. + 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; + } + + /// All YouTube ids in a text (e.g. HTML of comments), in order, without duplicates. + public static List FindYouTubeIds(string text) + { + return _YouTubeUrl().Matches(text).Select(m => m.Groups[1].Value).Distinct().ToList(); + } + + /// + /// Downloads into as ".ext" and returns the file name. + /// is a yt-dlp format selector. + /// + public async Task DownloadAsync(string url, string folder, string baseName, string format, IProgress? 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(); + 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"); + } + } +} diff --git a/VocaluxeSongServer/Sources/ISongSource.cs b/VocaluxeSongServer/Sources/ISongSource.cs new file mode 100644 index 00000000..4851d1a3 --- /dev/null +++ b/VocaluxeSongServer/Sources/ISongSource.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text; +using VocaluxeSongServer.Contracts; + +namespace VocaluxeSongServer.Sources +{ + /// A place where new songs can be found and downloaded (song database, song repository, ...). + public interface ISongSource + { + string Id { get; } + string Name { get; } + string Description { get; } + bool Enabled { get; } + /// Why the source is disabled, or empty. + string Hint { get; } + + Task> SearchAsync(string query, CancellationToken ct); + + /// + /// Downloads the song into (created by the caller, empty). Must leave a playable + /// UltraStar song there (text file + audio) or throw. + /// + Task DownloadAsync(string sourceId, string folder, IProgress progress, CancellationToken ct); + } + + public static class CSourceHelper + { + /// Lower case, without diacritics and punctuation, single spaces: for matching artists and titles. + 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)); + } + + /// True if every word of the query occurs in the text (both normalized). + public static bool Matches(string normalizedQuery, string text) + { + var normalized = " " + Normalize(text) + " "; + return normalizedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries).All(w => normalized.Contains(w)); + } + + /// A file or folder name without characters that are invalid on Windows or Linux. + 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; + } + + /// Copies a stream and reports the bytes copied. + public static async Task CopyAsync(Stream source, Stream target, Action 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); + } + } + } +} diff --git a/VocaluxeSongServer/appsettings.json b/VocaluxeSongServer/appsettings.json index d3fe3e5e..4d010509 100644 --- a/VocaluxeSongServer/appsettings.json +++ b/VocaluxeSongServer/appsettings.json @@ -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": "" } } diff --git a/VocaluxeSongServer/wwwroot/app.css b/VocaluxeSongServer/wwwroot/app.css new file mode 100644 index 00000000..19ba13db --- /dev/null +++ b/VocaluxeSongServer/wwwroot/app.css @@ -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; } +} diff --git a/VocaluxeSongServer/wwwroot/app.js b/VocaluxeSongServer/wwwroot/app.js new file mode 100644 index 00000000..c4ac2dd0 --- /dev/null +++ b/VocaluxeSongServer/wwwroot/app.js @@ -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"); diff --git a/VocaluxeSongServer/wwwroot/icon.svg b/VocaluxeSongServer/wwwroot/icon.svg new file mode 100644 index 00000000..aa519213 --- /dev/null +++ b/VocaluxeSongServer/wwwroot/icon.svg @@ -0,0 +1 @@ + diff --git a/VocaluxeSongServer/wwwroot/index.html b/VocaluxeSongServer/wwwroot/index.html new file mode 100644 index 00000000..f51626f5 --- /dev/null +++ b/VocaluxeSongServer/wwwroot/index.html @@ -0,0 +1,69 @@ + + + + + + + Karaoke Songs + + + + + + +
+

Karaoke Songs

+ +
+ +
+ + +
+
No downloads yet. Find songs under Search.
+
    +
    + +
    + +
    +
      +
      +
      + + + + +
      +

      Access key

      +

      This song server needs an access key.

      + + +
      +
      + + + + diff --git a/VocaluxeSongServer/wwwroot/manifest.webmanifest b/VocaluxeSongServer/wwwroot/manifest.webmanifest new file mode 100644 index 00000000..707901fe --- /dev/null +++ b/VocaluxeSongServer/wwwroot/manifest.webmanifest @@ -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" }] +}