Files
Vocaluxe/VocaluxeSongServer/Sources/CSongSearch.cs
T
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

224 lines
8.9 KiB
C#

using System.Collections.Concurrent;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using VocaluxeSongServer.Contracts;
using VocaluxeSongServer.Library;
namespace VocaluxeSongServer.Sources
{
/// <summary>Searches all enabled sources at once.</summary>
public sealed class CSongSearch
{
private readonly IReadOnlyList<ISongSource> _Sources;
private readonly CLibraryIndex _Index;
private readonly ILogger<CSongSearch> _Logger;
public CSongSearch(IEnumerable<ISongSource> sources, CLibraryIndex index, ILogger<CSongSearch> logger)
{
_Sources = sources.ToList();
_Index = index;
_Logger = logger;
}
public IReadOnlyList<ISongSource> 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<SearchResponse> SearchAsync(string query, CancellationToken ct)
{
var response = new SearchResponse();
if (CSourceHelper.Normalize(query).Length < 2)
{
return response;
}
var inLibrary = new HashSet<string>((_Index.Current?.Dto.Songs ?? new List<SongDto>()).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<SearchResultDto>(), 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;
}
}
/// <summary>
/// Runs downloads one after another in the background. A song is downloaded into a temporary folder and moved to
/// "&lt;library&gt;/&lt;DownloadFolder&gt;/&lt;source&gt;/&lt;Artist - Title&gt;" when complete; then the library is rescanned.
/// </summary>
public sealed class CDownloadQueue : BackgroundService
{
private sealed class CJob
{
public required DownloadJobDto Dto;
public required ISongSource Source;
}
private readonly Channel<CJob> _Queue = Channel.CreateUnbounded<CJob>();
private readonly ConcurrentDictionary<string, CJob> _Jobs = new();
private readonly CLibraryIndex _Index;
private readonly CSongSearch _Search;
private readonly CLibraryOptions _Options;
private readonly ILogger<CDownloadQueue> _Logger;
public CDownloadQueue(CLibraryIndex index, CSongSearch search, IOptions<CLibraryOptions> options, ILogger<CDownloadQueue> logger)
{
_Index = index;
_Search = search;
_Options = options.Value;
_Logger = logger;
}
public IEnumerable<DownloadJobDto> Jobs => _Jobs.Values.Select(j => j.Dto).OrderByDescending(j => j.Created);
/// <summary>Queues a download. Returns the existing job if the same song is already queued or running.</summary>
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<double>(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
}
}
}
}
}