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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user