using octo_fiesta.Models.Domain; using octo_fiesta.Models.Settings; using octo_fiesta.Models.Download; using octo_fiesta.Models.Search; using octo_fiesta.Models.Subsonic; using octo_fiesta.Services.Local; using octo_fiesta.Services.Subsonic; 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 IServiceProvider ServiceProvider; protected readonly string DownloadPath; protected readonly string CachePath; 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, IServiceProvider serviceProvider, ILogger logger) { Configuration = configuration; LocalLibraryService = localLibraryService; MetadataService = metadataService; SubsonicSettings = subsonicSettings; ServiceProvider = serviceProvider; Logger = logger; DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; CachePath = PathHelper.GetCachePath(); if (!Directory.Exists(DownloadPath)) { Directory.CreateDirectory(DownloadPath); } if (!Directory.Exists(CachePath)) { Directory.CreateDirectory(CachePath); } } #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 DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); return IOFile.OpenRead(localPath); } public DownloadInfo? GetDownloadStatus(string songId) { ActiveDownloads.TryGetValue(songId, out var info); return info; } public async Task GetLocalPathIfExistsAsync(string externalProvider, string externalId) { if (externalProvider != ProviderName) { return null; } // Check local library var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); if (localPath != null && IOFile.Exists(localPath)) { return localPath; } // Check cache directory var cachedPath = GetCachedFilePath(externalProvider, externalId); if (cachedPath != null && IOFile.Exists(cachedPath)) { return cachedPath; } return null; } 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}"; var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; // Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests await DownloadLock.WaitAsync(cancellationToken); try { // Check if already downloaded (skip for cache mode as we want to check cache folder) if (!isCache) { var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); if (existingPath != null && IOFile.Exists(existingPath)) { Logger.LogInformation("Song already downloaded: {Path}", existingPath); return existingPath; } } else { // For cache mode, check if file exists in cache directory var cachedPath = GetCachedFilePath(externalProvider, externalId); if (cachedPath != null && IOFile.Exists(cachedPath)) { Logger.LogInformation("Song found in cache: {Path}", cachedPath); // Update file access time for cache cleanup logic IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow); return cachedPath; } } // Check if download in progress if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId); // Release lock while waiting DownloadLock.Release(); 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"); } // Get metadata // In Album mode, fetch the full album first to ensure AlbumArtist is correctly set Song? song = null; if (SubsonicSettings.DownloadMode == DownloadMode.Album) { // First try to get the song to extract album ID var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId); if (tempSong != null && !string.IsNullOrEmpty(tempSong.AlbumId)) { var albumExternalId = ExtractExternalIdFromAlbumId(tempSong.AlbumId); if (!string.IsNullOrEmpty(albumExternalId)) { // Get full album with correct AlbumArtist var album = await MetadataService.GetAlbumAsync(externalProvider, albumExternalId); if (album != null) { // Find the track in the album song = album.Songs.FirstOrDefault(s => s.ExternalId == externalId); } } } } // Fallback to individual song fetch if not in Album mode or album fetch failed if (song == null) { 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; var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); downloadInfo.Status = DownloadStatus.Completed; downloadInfo.LocalPath = localPath; downloadInfo.CompletedAt = DateTime.UtcNow; song.LocalPath = localPath; // Check if this track belongs to a playlist and update M3U try { var playlistSyncService = ServiceProvider.GetService(typeof(PlaylistSyncService)) as PlaylistSyncService; if (playlistSyncService != null) { var playlistId = playlistSyncService.GetPlaylistIdForTrack(songId); if (playlistId != null) { Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId); await playlistSyncService.AddTrackToM3UAsync(playlistId, song, localPath); } } } catch (Exception ex) { Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId); } // Only register and scan if NOT in cache mode if (!isCache) { 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); } } } else { Logger.LogInformation("Cache mode: skipping library registration and scan"); } Logger.LogInformation("Download completed: {Path}", localPath); return localPath; } catch (Exception ex) { if (ActiveDownloads.TryGetValue(songId, out var downloadInfo)) { 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; } // Check if download is already in progress or recently completed var songId = $"ext-{ProviderName}-{track.ExternalId}"; if (ActiveDownloads.TryGetValue(songId, out var activeDownload)) { if (activeDownload.Status == DownloadStatus.InProgress) { Logger.LogDebug("Track {TrackId} download already in progress, skipping", track.ExternalId); continue; } if (activeDownload.Status == DownloadStatus.Completed) { Logger.LogDebug("Track {TrackId} already downloaded in this session, 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; } } /// /// Gets the cached file path for a given provider and external ID /// Returns null if no cached file exists /// protected string? GetCachedFilePath(string provider, string externalId) { try { // Search for cached files matching the pattern: {provider}_{externalId}.* var pattern = $"{provider}_{externalId}.*"; var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories); if (files.Length > 0) { return files[0]; // Return first match } return null; } catch (Exception ex) { Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId); return null; } } #endregion }