- 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>
74 lines
2.9 KiB
C#
74 lines
2.9 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|