using octo_fiesta.Models; using octo_fiesta.Services.Local; using octo_fiesta.Services.Deezer; using TagLib; using IOFile = System.IO.File; namespace octo_fiesta.Services.Common; /// /// Abstract base class for download services. /// Implements common download logic, tracking, and metadata writing. /// Subclasses implement provider-specific download and authentication logic. /// public abstract class BaseDownloadService : IDownloadService { protected readonly IConfiguration Configuration; protected readonly ILocalLibraryService LocalLibraryService; protected readonly IMusicMetadataService MetadataService; protected readonly SubsonicSettings SubsonicSettings; protected readonly ILogger Logger; protected readonly string DownloadPath; protected readonly Dictionary ActiveDownloads = new(); protected readonly SemaphoreSlim DownloadLock = new(1, 1); /// /// Provider name (e.g., "deezer", "qobuz") /// protected abstract string ProviderName { get; } protected BaseDownloadService( IConfiguration configuration, ILocalLibraryService localLibraryService, IMusicMetadataService metadataService, SubsonicSettings subsonicSettings, ILogger logger) { Configuration = configuration; LocalLibraryService = localLibraryService; MetadataService = metadataService; SubsonicSettings = subsonicSettings; Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; if (!Directory.Exists(DownloadPath)) { Directory.CreateDirectory(DownloadPath); } } #region IDownloadService Implementation public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); } public async Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken); return IOFile.OpenRead(localPath); } public DownloadInfo? GetDownloadStatus(string songId) { ActiveDownloads.TryGetValue(songId, out var info); return info; } public abstract Task IsAvailableAsync(); public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) { if (externalProvider != ProviderName) { Logger.LogWarning("Provider '{Provider}' is not supported for album download", externalProvider); return; } _ = Task.Run(async () => { try { await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId); } catch (Exception ex) { Logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId); } }); } #endregion #region Template Methods (to be implemented by subclasses) /// /// Downloads a track and saves it to disk. /// Subclasses implement provider-specific logic (encryption, authentication, etc.) /// /// External track ID /// Song metadata /// Cancellation token /// Local file path where the track was saved protected abstract Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken); /// /// Extracts the external album ID from the internal album ID format. /// Example: "ext-deezer-album-123456" -> "123456" /// protected abstract string? ExtractExternalIdFromAlbumId(string albumId); #endregion #region Common Download Logic /// /// Internal method for downloading a song with control over album download triggering /// protected async Task DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) { if (externalProvider != ProviderName) { throw new NotSupportedException($"Provider '{externalProvider}' is not supported"); } var songId = $"ext-{externalProvider}-{externalId}"; // Check if already downloaded var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); if (existingPath != null && IOFile.Exists(existingPath)) { Logger.LogInformation("Song already downloaded: {Path}", existingPath); return existingPath; } // Check if download in progress if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { Logger.LogInformation("Download already in progress for {SongId}", songId); while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { await Task.Delay(500, cancellationToken); } if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) { return activeDownload.LocalPath; } throw new Exception(activeDownload?.ErrorMessage ?? "Download failed"); } await DownloadLock.WaitAsync(cancellationToken); try { // Get metadata var song = await MetadataService.GetSongAsync(externalProvider, externalId); if (song == null) { throw new Exception("Song not found"); } var downloadInfo = new DownloadInfo { SongId = songId, ExternalId = externalId, ExternalProvider = externalProvider, Status = DownloadStatus.InProgress, StartedAt = DateTime.UtcNow }; ActiveDownloads[songId] = downloadInfo; try { var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); downloadInfo.Status = DownloadStatus.Completed; downloadInfo.LocalPath = localPath; downloadInfo.CompletedAt = DateTime.UtcNow; song.LocalPath = localPath; await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath); // Trigger a Subsonic library rescan (with debounce) _ = Task.Run(async () => { try { await LocalLibraryService.TriggerLibraryScanAsync(); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to trigger library scan after download"); } }); // If download mode is Album and triggering is enabled, start background download of remaining tracks if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) { var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId); if (!string.IsNullOrEmpty(albumExternalId)) { Logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId); DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId); } } Logger.LogInformation("Download completed: {Path}", localPath); return localPath; } catch (Exception ex) { downloadInfo.Status = DownloadStatus.Failed; downloadInfo.ErrorMessage = ex.Message; Logger.LogError(ex, "Download failed for {SongId}", songId); throw; } } finally { DownloadLock.Release(); } } protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId) { Logger.LogInformation("Starting background download for album {AlbumId} (excluding track {TrackId})", albumExternalId, excludeTrackExternalId); var album = await MetadataService.GetAlbumAsync(ProviderName, albumExternalId); if (album == null) { Logger.LogWarning("Album {AlbumId} not found, cannot download remaining tracks", albumExternalId); return; } var tracksToDownload = album.Songs .Where(s => s.ExternalId != excludeTrackExternalId && !string.IsNullOrEmpty(s.ExternalId)) .ToList(); Logger.LogInformation("Found {Count} additional tracks to download for album '{AlbumTitle}'", tracksToDownload.Count, album.Title); foreach (var track in tracksToDownload) { try { var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(ProviderName, track.ExternalId!); if (existingPath != null && IOFile.Exists(existingPath)) { Logger.LogDebug("Track {TrackId} already downloaded, skipping", track.ExternalId); continue; } Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title); await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to download track {TrackId} '{Title}'", track.ExternalId, track.Title); } } Logger.LogInformation("Completed background download for album '{AlbumTitle}'", album.Title); } #endregion #region Common Metadata Writing /// /// Writes ID3/Vorbis metadata and cover art to the audio file /// protected async Task WriteMetadataAsync(string filePath, Song song, CancellationToken cancellationToken) { try { Logger.LogInformation("Writing metadata to: {Path}", filePath); using var tagFile = TagLib.File.Create(filePath); // Basic metadata tagFile.Tag.Title = song.Title; tagFile.Tag.Performers = new[] { song.Artist }; tagFile.Tag.Album = song.Album; tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist }; if (song.Track.HasValue) tagFile.Tag.Track = (uint)song.Track.Value; if (song.TotalTracks.HasValue) tagFile.Tag.TrackCount = (uint)song.TotalTracks.Value; if (song.DiscNumber.HasValue) tagFile.Tag.Disc = (uint)song.DiscNumber.Value; if (song.Year.HasValue) tagFile.Tag.Year = (uint)song.Year.Value; if (!string.IsNullOrEmpty(song.Genre)) tagFile.Tag.Genres = new[] { song.Genre }; if (song.Bpm.HasValue) tagFile.Tag.BeatsPerMinute = (uint)song.Bpm.Value; if (song.Contributors.Count > 0) tagFile.Tag.Composers = song.Contributors.ToArray(); if (!string.IsNullOrEmpty(song.Copyright)) tagFile.Tag.Copyright = song.Copyright; var comments = new List(); if (!string.IsNullOrEmpty(song.Isrc)) comments.Add($"ISRC: {song.Isrc}"); if (comments.Count > 0) tagFile.Tag.Comment = string.Join(" | ", comments); // Download and embed cover art var coverUrl = song.CoverArtUrlLarge ?? song.CoverArtUrl; if (!string.IsNullOrEmpty(coverUrl)) { try { var coverData = await DownloadCoverArtAsync(coverUrl, cancellationToken); if (coverData != null && coverData.Length > 0) { var mimeType = coverUrl.Contains(".png") ? "image/png" : "image/jpeg"; var picture = new TagLib.Picture { Type = TagLib.PictureType.FrontCover, MimeType = mimeType, Description = "Cover", Data = new TagLib.ByteVector(coverData) }; tagFile.Tag.Pictures = new TagLib.IPicture[] { picture }; Logger.LogInformation("Cover art embedded: {Size} bytes", coverData.Length); } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to download cover art from {Url}", coverUrl); } } tagFile.Save(); Logger.LogInformation("Metadata written successfully to: {Path}", filePath); } catch (Exception ex) { Logger.LogError(ex, "Failed to write metadata to: {Path}", filePath); } } /// /// Downloads cover art from a URL /// protected async Task DownloadCoverArtAsync(string url, CancellationToken cancellationToken) { try { using var httpClient = new HttpClient(); var response = await httpClient.GetAsync(url, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsByteArrayAsync(cancellationToken); } catch (Exception ex) { Logger.LogWarning(ex, "Failed to download cover art from {Url}", url); return null; } } #endregion #region Utility Methods /// /// Ensures a directory exists, creating it and all parent directories if necessary /// protected void EnsureDirectoryExists(string path) { try { if (!Directory.Exists(path)) { Directory.CreateDirectory(path); Logger.LogDebug("Created directory: {Path}", path); } } catch (Exception ex) { Logger.LogError(ex, "Failed to create directory: {Path}", path); throw; } } #endregion }