feat: add subsonic proxy architecture with external music services

- Add MusicModels (Song, Artist, Album, SearchResult, DownloadInfo)
- Add IMusicMetadataService and DeezerMetadataService for external search
- Add IDownloadService and DeezspotDownloadService for downloads
- Add LocalLibraryService for managing downloaded songs cache
- Add custom endpoints: search3, stream, getSong, getAlbum, getCoverArt
- Configure dependency injection for all services
This commit is contained in:
V1ck3s
2025-12-08 15:09:39 +01:00
committed by Vickes
parent 1e8bfd108e
commit 6b07ac7646
9 changed files with 1493 additions and 66 deletions

View File

@@ -0,0 +1,275 @@
using octo_fiesta.Models;
using System.Text.Json;
namespace octo_fiesta.Services;
/// <summary>
/// Implémentation du service de métadonnées utilisant l'API Deezer (gratuite, pas besoin de clé)
/// </summary>
public class DeezerMetadataService : IMusicMetadataService
{
private readonly HttpClient _httpClient;
private const string BaseUrl = "https://api.deezer.com";
public DeezerMetadataService(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient();
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var songs = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var track in data.EnumerateArray())
{
songs.Add(ParseDeezerTrack(track));
}
}
return songs;
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var album in data.EnumerateArray())
{
albums.Add(ParseDeezerAlbum(album));
}
}
return albums;
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var artist in data.EnumerateArray())
{
artists.Add(ParseDeezerArtist(artist));
}
}
return artists;
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
{
// Exécuter les recherches en parallèle
var songsTask = SearchSongsAsync(query, songLimit);
var albumsTask = SearchAlbumsAsync(query, albumLimit);
var artistsTask = SearchArtistsAsync(query, artistLimit);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
return new SearchResult
{
Songs = await songsTask,
Albums = await albumsTask,
Artists = await artistsTask
};
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/track/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var track = JsonDocument.Parse(json).RootElement;
if (track.TryGetProperty("error", out _)) return null;
return ParseDeezerTrack(track);
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/album/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var albumElement = JsonDocument.Parse(json).RootElement;
if (albumElement.TryGetProperty("error", out _)) return null;
var album = ParseDeezerAlbum(albumElement);
// Récupérer les chansons de l'album
if (albumElement.TryGetProperty("tracks", out var tracks) &&
tracks.TryGetProperty("data", out var tracksData))
{
foreach (var track in tracksData.EnumerateArray())
{
album.Songs.Add(ParseDeezerTrack(track));
}
}
return album;
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/artist/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var artist = JsonDocument.Parse(json).RootElement;
if (artist.TryGetProperty("error", out _)) return null;
return ParseDeezerArtist(artist);
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return new List<Album>();
var url = $"{BaseUrl}/artist/{externalId}/albums";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var album in data.EnumerateArray())
{
albums.Add(ParseDeezerAlbum(album));
}
}
return albums;
}
private Song ParseDeezerTrack(JsonElement track)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
return new Song
{
Id = $"ext-deezer-{externalId}",
Title = track.GetProperty("title").GetString() ?? "",
Artist = track.TryGetProperty("artist", out var artist)
? artist.GetProperty("name").GetString() ?? ""
: "",
ArtistId = track.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-{artistForId.GetProperty("id").GetInt64()}"
: null,
Album = track.TryGetProperty("album", out var album)
? album.GetProperty("title").GetString() ?? ""
: "",
AlbumId = track.TryGetProperty("album", out var albumForId)
? $"ext-deezer-{albumForId.GetProperty("id").GetInt64()}"
: null,
Duration = track.TryGetProperty("duration", out var duration)
? duration.GetInt32()
: null,
Track = track.TryGetProperty("track_position", out var trackPos)
? trackPos.GetInt32()
: null,
CoverArtUrl = track.TryGetProperty("album", out var albumForCover) &&
albumForCover.TryGetProperty("cover_medium", out var cover)
? cover.GetString()
: null,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId
};
}
private Album ParseDeezerAlbum(JsonElement album)
{
var externalId = album.GetProperty("id").GetInt64().ToString();
return new Album
{
Id = $"ext-deezer-{externalId}",
Title = album.GetProperty("title").GetString() ?? "",
Artist = album.TryGetProperty("artist", out var artist)
? artist.GetProperty("name").GetString() ?? ""
: "",
ArtistId = album.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-{artistForId.GetProperty("id").GetInt64()}"
: null,
Year = album.TryGetProperty("release_date", out var releaseDate)
? int.TryParse(releaseDate.GetString()?.Split('-')[0], out var year) ? year : null
: null,
SongCount = album.TryGetProperty("nb_tracks", out var nbTracks)
? nbTracks.GetInt32()
: null,
CoverArtUrl = album.TryGetProperty("cover_medium", out var cover)
? cover.GetString()
: null,
Genre = album.TryGetProperty("genres", out var genres) &&
genres.TryGetProperty("data", out var genresData) &&
genresData.GetArrayLength() > 0
? genresData[0].GetProperty("name").GetString()
: null,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId
};
}
private Artist ParseDeezerArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
return new Artist
{
Id = $"ext-deezer-{externalId}",
Name = artist.GetProperty("name").GetString() ?? "",
ImageUrl = artist.TryGetProperty("picture_medium", out var picture)
? picture.GetString()
: null,
AlbumCount = artist.TryGetProperty("nb_album", out var nbAlbum)
? nbAlbum.GetInt32()
: null,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId
};
}
}

View File

@@ -0,0 +1,244 @@
using octo_fiesta.Models;
using System.Diagnostics;
namespace octo_fiesta.Services;
/// <summary>
/// Implémentation du service de téléchargement utilisant Deezspot (ou similaire)
/// Cette implémentation est un placeholder - à adapter selon l'outil de téléchargement choisi
/// </summary>
public class DeezspotDownloadService : IDownloadService
{
private readonly IConfiguration _configuration;
private readonly ILocalLibraryService _localLibraryService;
private readonly IMusicMetadataService _metadataService;
private readonly ILogger<DeezspotDownloadService> _logger;
private readonly Dictionary<string, DownloadInfo> _activeDownloads = new();
private readonly SemaphoreSlim _downloadLock = new(1, 1);
private readonly string _downloadPath;
private readonly string? _deezspotPath;
public DeezspotDownloadService(
IConfiguration configuration,
ILocalLibraryService localLibraryService,
IMusicMetadataService metadataService,
ILogger<DeezspotDownloadService> logger)
{
_configuration = configuration;
_localLibraryService = localLibraryService;
_metadataService = metadataService;
_logger = logger;
_downloadPath = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
_deezspotPath = configuration["Deezspot:ExecutablePath"];
if (!Directory.Exists(_downloadPath))
{
Directory.CreateDirectory(_downloadPath);
}
}
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
var songId = $"ext-{externalProvider}-{externalId}";
// Vérifier si déjà téléchargé
var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && File.Exists(existingPath))
{
return existingPath;
}
// Vérifier si téléchargement en cours
if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
// Attendre la fin du téléchargement en cours
while (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
{
// Récupérer les métadonnées pour le nom de fichier
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 ExecuteDownloadAsync(externalProvider, externalId, song, cancellationToken);
downloadInfo.Status = DownloadStatus.Completed;
downloadInfo.LocalPath = localPath;
downloadInfo.CompletedAt = DateTime.UtcNow;
// Enregistrer dans la bibliothèque locale
song.LocalPath = localPath;
await _localLibraryService.RegisterDownloadedSongAsync(song, localPath);
return localPath;
}
catch (Exception ex)
{
downloadInfo.Status = DownloadStatus.Failed;
downloadInfo.ErrorMessage = ex.Message;
throw;
}
}
finally
{
_downloadLock.Release();
}
}
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
// Pour le streaming à la volée, on télécharge d'abord le fichier puis on le stream
// Une implémentation plus avancée pourrait utiliser des pipes pour streamer pendant le téléchargement
var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken);
return File.OpenRead(localPath);
}
public DownloadInfo? GetDownloadStatus(string songId)
{
_activeDownloads.TryGetValue(songId, out var info);
return info;
}
public async Task<bool> IsAvailableAsync()
{
if (string.IsNullOrEmpty(_deezspotPath))
{
_logger.LogWarning("Deezspot path not configured");
return false;
}
if (!File.Exists(_deezspotPath))
{
_logger.LogWarning("Deezspot executable not found at {Path}", _deezspotPath);
return false;
}
await Task.CompletedTask;
return true;
}
private async Task<string> ExecuteDownloadAsync(string provider, string externalId, Song song, CancellationToken cancellationToken)
{
// Générer un nom de fichier sécurisé
var safeTitle = SanitizeFileName(song.Title);
var safeArtist = SanitizeFileName(song.Artist);
var fileName = $"{safeArtist} - {safeTitle}.mp3";
var outputPath = Path.Combine(_downloadPath, fileName);
// Éviter les conflits de noms
var counter = 1;
while (File.Exists(outputPath))
{
fileName = $"{safeArtist} - {safeTitle} ({counter}).mp3";
outputPath = Path.Combine(_downloadPath, fileName);
counter++;
}
if (string.IsNullOrEmpty(_deezspotPath))
{
throw new InvalidOperationException("Deezspot executable path not configured. Set 'Deezspot:ExecutablePath' in configuration.");
}
// Construire la commande Deezspot
// Note: La syntaxe exacte dépend de la version de Deezspot utilisée
var trackUrl = provider == "deezer"
? $"https://www.deezer.com/track/{externalId}"
: $"https://open.spotify.com/track/{externalId}";
var processInfo = new ProcessStartInfo
{
FileName = _deezspotPath,
Arguments = $"download \"{trackUrl}\" -o \"{_downloadPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
_logger.LogInformation("Starting download: {Command} {Args}", processInfo.FileName, processInfo.Arguments);
using var process = new Process { StartInfo = processInfo };
process.Start();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync(cancellationToken);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
{
_logger.LogError("Download failed: {Error}", error);
throw new Exception($"Download failed: {error}");
}
// Chercher le fichier téléchargé (Deezspot peut utiliser son propre nommage)
var downloadedFiles = Directory.GetFiles(_downloadPath, "*.mp3")
.OrderByDescending(f => File.GetCreationTime(f))
.ToList();
if (downloadedFiles.Any())
{
var latestFile = downloadedFiles.First();
// Si le fichier a un nom différent, on peut le renommer
if (latestFile != outputPath && File.GetCreationTime(latestFile) > DateTime.UtcNow.AddMinutes(-5))
{
_logger.LogInformation("Downloaded file: {File}", latestFile);
return latestFile;
}
}
if (File.Exists(outputPath))
{
return outputPath;
}
throw new Exception("Download completed but file not found");
}
private string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(fileName
.Select(c => invalidChars.Contains(c) ? '_' : c)
.ToArray());
// Limiter la longueur
if (sanitized.Length > 100)
{
sanitized = sanitized.Substring(0, 100);
}
return sanitized.Trim();
}
}

View File

@@ -0,0 +1,37 @@
using octo_fiesta.Models;
namespace octo_fiesta.Services;
/// <summary>
/// Interface pour le service de téléchargement de musique (Deezspot ou autre)
/// </summary>
public interface IDownloadService
{
/// <summary>
/// Télécharge une chanson depuis un provider externe
/// </summary>
/// <param name="externalProvider">Le provider (deezer, spotify)</param>
/// <param name="externalId">L'ID sur le provider externe</param>
/// <param name="cancellationToken">Token d'annulation</param>
/// <returns>Le chemin du fichier téléchargé</returns>
Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Télécharge une chanson et stream le résultat au fur et à mesure
/// </summary>
/// <param name="externalProvider">Le provider (deezer, spotify)</param>
/// <param name="externalId">L'ID sur le provider externe</param>
/// <param name="cancellationToken">Token d'annulation</param>
/// <returns>Un stream du fichier audio</returns>
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Vérifie si une chanson est en cours de téléchargement
/// </summary>
DownloadInfo? GetDownloadStatus(string songId);
/// <summary>
/// Vérifie si le service est correctement configuré et fonctionnel
/// </summary>
Task<bool> IsAvailableAsync();
}

View File

@@ -0,0 +1,53 @@
using octo_fiesta.Models;
namespace octo_fiesta.Services;
/// <summary>
/// Interface pour le service de recherche de métadonnées musicales externes
/// (Deezer API, Spotify API, MusicBrainz, etc.)
/// </summary>
public interface IMusicMetadataService
{
/// <summary>
/// Recherche des chansons sur les providers externes
/// </summary>
/// <param name="query">Terme de recherche</param>
/// <param name="limit">Nombre maximum de résultats</param>
/// <returns>Liste des chansons trouvées</returns>
Task<List<Song>> SearchSongsAsync(string query, int limit = 20);
/// <summary>
/// Recherche des albums sur les providers externes
/// </summary>
Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20);
/// <summary>
/// Recherche des artistes sur les providers externes
/// </summary>
Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20);
/// <summary>
/// Recherche combinée (chansons, albums, artistes)
/// </summary>
Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20);
/// <summary>
/// Récupère les détails d'une chanson externe
/// </summary>
Task<Song?> GetSongAsync(string externalProvider, string externalId);
/// <summary>
/// Récupère les détails d'un album externe avec ses chansons
/// </summary>
Task<Album?> GetAlbumAsync(string externalProvider, string externalId);
/// <summary>
/// Récupère les détails d'un artiste externe
/// </summary>
Task<Artist?> GetArtistAsync(string externalProvider, string externalId);
/// <summary>
/// Récupère les albums d'un artiste
/// </summary>
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
}

View File

@@ -0,0 +1,161 @@
using octo_fiesta.Models;
namespace octo_fiesta.Services;
/// <summary>
/// Interface pour la gestion de la bibliothèque locale de musiques
/// </summary>
public interface ILocalLibraryService
{
/// <summary>
/// Vérifie si une chanson externe existe déjà localement
/// </summary>
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Enregistre une chanson téléchargée dans la bibliothèque locale
/// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary>
/// Récupère le mapping entre ID externe et ID local
/// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Parse un ID de chanson pour déterminer s'il est externe ou local
/// </summary>
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
}
/// <summary>
/// Implémentation du service de bibliothèque locale
/// Utilise un fichier JSON simple pour stocker les mappings (peut être remplacé par une BDD)
/// </summary>
public class LocalLibraryService : ILocalLibraryService
{
private readonly string _mappingFilePath;
private readonly string _downloadDirectory;
private Dictionary<string, LocalSongMapping>? _mappings;
private readonly SemaphoreSlim _lock = new(1, 1);
public LocalLibraryService(IConfiguration configuration)
{
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
if (!Directory.Exists(_downloadDirectory))
{
Directory.CreateDirectory(_downloadDirectory);
}
}
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
{
var mappings = await LoadMappingsAsync();
var key = $"{externalProvider}:{externalId}";
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
{
return mapping.LocalPath;
}
return null;
}
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
{
if (song.ExternalProvider == null || song.ExternalId == null) return;
await _lock.WaitAsync();
try
{
var mappings = await LoadMappingsAsync();
var key = $"{song.ExternalProvider}:{song.ExternalId}";
mappings[key] = new LocalSongMapping
{
ExternalProvider = song.ExternalProvider,
ExternalId = song.ExternalId,
LocalPath = localPath,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
DownloadedAt = DateTime.UtcNow
};
await SaveMappingsAsync(mappings);
}
finally
{
_lock.Release();
}
}
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
{
// Pour l'instant, on retourne null car on n'a pas encore d'intégration
// avec le serveur Subsonic pour récupérer l'ID local après scan
await Task.CompletedTask;
return null;
}
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
{
if (songId.StartsWith("ext-"))
{
var parts = songId.Split('-', 3);
if (parts.Length == 3)
{
return (true, parts[1], parts[2]);
}
}
return (false, null, null);
}
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
{
if (_mappings != null) return _mappings;
if (File.Exists(_mappingFilePath))
{
var json = await File.ReadAllTextAsync(_mappingFilePath);
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
?? new Dictionary<string, LocalSongMapping>();
}
else
{
_mappings = new Dictionary<string, LocalSongMapping>();
}
return _mappings;
}
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
{
_mappings = mappings;
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_mappingFilePath, json);
}
public string GetDownloadDirectory() => _downloadDirectory;
}
/// <summary>
/// Représente le mapping entre une chanson externe et son fichier local
/// </summary>
public class LocalSongMapping
{
public string ExternalProvider { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string LocalPath { get; set; } = string.Empty;
public string? LocalSubsonicId { get; set; }
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public DateTime DownloadedAt { get; set; }
}