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:
Simon DiesenreiterandClaude Opus 5.5 committed 2026-11-05 23:15:48 +01:00
1 parent efcc63a640
commit 1e65864c8f
19 files changed
+1862 -12

No files matched your search

+85
View File
@@ -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&#039;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"));
}
}
}