using System.Collections.Concurrent; using System.Threading.Channels; using Microsoft.Extensions.Options; using VocaluxeSongServer.Contracts; using VocaluxeSongServer.Library; namespace VocaluxeSongServer.Sources { /// Searches all enabled sources at once. public sealed class CSongSearch { private readonly IReadOnlyList _Sources; private readonly CLibraryIndex _Index; private readonly ILogger _Logger; public CSongSearch(IEnumerable sources, CLibraryIndex index, ILogger logger) { _Sources = sources.ToList(); _Index = index; _Logger = logger; } public IReadOnlyList 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 SearchAsync(string query, CancellationToken ct) { var response = new SearchResponse(); if (CSourceHelper.Normalize(query).Length < 2) { return response; } var inLibrary = new HashSet((_Index.Current?.Dto.Songs ?? new List()).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(), 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; } } /// /// 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. /// public sealed class CDownloadQueue : BackgroundService { private sealed class CJob { public required DownloadJobDto Dto; public required ISongSource Source; } private readonly Channel _Queue = Channel.CreateUnbounded(); private readonly ConcurrentDictionary _Jobs = new(); private readonly CLibraryIndex _Index; private readonly CSongSearch _Search; private readonly CLibraryOptions _Options; private readonly ILogger _Logger; public CDownloadQueue(CLibraryIndex index, CSongSearch search, IOptions options, ILogger logger) { _Index = index; _Search = search; _Options = options.Value; _Logger = logger; } public IEnumerable Jobs => _Jobs.Values.Select(j => j.Dto).OrderByDescending(j => j.Created); /// Queues a download. Returns the existing job if the same song is already queued or running. 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(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 } } } } }