Files
Vocaluxe/VocaluxeSongServer/Sources/ISongSource.cs
Simon DiesenreiterandClaude Opus 5.5 1e65864c8f 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>
2026-11-05 23:15:48 +01:00

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);
}
}
}
}