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