- 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>
224 lines
8.9 KiB
C#
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
|
|
/// "<library>/<DownloadFolder>/<source>/<Artist - Title>" 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|