diff --git a/octo-fiesta/Controllers/SubSonicController.cs b/octo-fiesta/Controllers/SubSonicController.cs index 65223a4..a53f378 100644 --- a/octo-fiesta/Controllers/SubSonicController.cs +++ b/octo-fiesta/Controllers/SubSonicController.cs @@ -1,10 +1,9 @@ -using Microsoft.AspNetCore.Mvc; -using System.Net.Http; -using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using System.Xml.Linq; using System.Text.Json; using Microsoft.Extensions.Options; using octo_fiesta.Models; +using octo_fiesta.Services; namespace octo_fiesta.Controllers; @@ -14,42 +13,57 @@ public class SubsonicController : ControllerBase { private readonly HttpClient _httpClient; private readonly SubsonicSettings _subsonicSettings; - public SubsonicController(IHttpClientFactory httpClientFactory, IOptions subsonicSettings) + private readonly IMusicMetadataService _metadataService; + private readonly ILocalLibraryService _localLibraryService; + private readonly IDownloadService _downloadService; + + public SubsonicController( + IHttpClientFactory httpClientFactory, + IOptions subsonicSettings, + IMusicMetadataService metadataService, + ILocalLibraryService localLibraryService, + IDownloadService downloadService) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; + _metadataService = metadataService; + _localLibraryService = localLibraryService; + _downloadService = downloadService; if (string.IsNullOrWhiteSpace(_subsonicSettings.Url)) { throw new Exception("Error: Environment variable SUBSONIC_URL is not set."); } } - - // Extract all parameters (query + body) - private async Task> ExtractAllParameters() - { - var parameters = new Dictionary(); - - // Get query parameters - foreach (var query in Request.Query) - { - parameters[query.Key] = query.Value.ToString(); - } - - // Get body parameters (JSON) - if (Request.ContentLength > 0 && Request.ContentType?.Contains("application/json") == true) - { - using var reader = new StreamReader(Request.Body); - var body = await reader.ReadToEndAsync(); - + + // Extract all parameters (query + body) + private async Task> ExtractAllParameters() + { + var parameters = new Dictionary(); + + // Get query parameters + foreach (var query in Request.Query) + { + parameters[query.Key] = query.Value.ToString(); + } + + // Get body parameters (JSON) + if (Request.ContentLength > 0 && Request.ContentType?.Contains("application/json") == true) + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(); + if (!string.IsNullOrEmpty(body)) { try { var bodyParams = JsonSerializer.Deserialize>(body); - foreach (var param in bodyParams) + if (bodyParams != null) { - parameters[param.Key] = param.Value?.ToString() ?? ""; + foreach (var param in bodyParams) + { + parameters[param.Key] = param.Value?.ToString() ?? ""; + } } } catch (JsonException) @@ -57,22 +71,22 @@ public class SubsonicController : ControllerBase } } - } - - return parameters; - } - - private async Task<(object Body, string? ContentType)> RelayToSubsonic(string endpoint, Dictionary parameters) - { - var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); - var url = $"{_subsonicSettings.Url}/{endpoint}?{query}"; - HttpResponseMessage response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsByteArrayAsync(); - var contentType = response.Content.Headers.ContentType?.ToString(); - return (body, contentType); - } - + } + + return parameters; + } + + private async Task<(object Body, string? ContentType)> RelayToSubsonic(string endpoint, Dictionary parameters) + { + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + var url = $"{_subsonicSettings.Url}/{endpoint}?{query}"; + HttpResponseMessage response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsByteArrayAsync(); + var contentType = response.Content.Headers.ContentType?.ToString(); + return (body, contentType); + } + [HttpGet, HttpPost] [Route("ping")] public async Task Ping() @@ -84,21 +98,534 @@ public class SubsonicController : ControllerBase return Ok(new { status }); } - // Generic endpoint to handle all subsonic API calls + /// + /// Endpoint search3 personnalisé - fusionne les résultats locaux et externes + /// [HttpGet, HttpPost] - [Route("{**endpoint}")] - public async Task GenericEndpoint(string endpoint) + [Route("rest/search3")] + [Route("rest/search3.view")] + public async Task Search3() { var parameters = await ExtractAllParameters(); + var query = parameters.GetValueOrDefault("query", ""); + var format = parameters.GetValueOrDefault("f", "xml"); + + if (string.IsNullOrWhiteSpace(query)) + { + return CreateSubsonicResponse(format, "searchResult3", new { }); + } + + // Lancer les deux recherches en parallèle + var subsonicTask = RelayToSubsonicSafe("rest/search3", parameters); + var externalTask = _metadataService.SearchAllAsync( + query, + int.TryParse(parameters.GetValueOrDefault("songCount", "20"), out var sc) ? sc : 20, + int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20, + int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20 + ); + + await Task.WhenAll(subsonicTask, externalTask); + + var subsonicResult = await subsonicTask; + var externalResult = await externalTask; + + // Fusionner les résultats + return MergeSearchResults(subsonicResult, externalResult, format); + } + + /// + /// Endpoint stream personnalisé - télécharge à la volée si nécessaire + /// + [HttpGet, HttpPost] + [Route("rest/stream")] + [Route("rest/stream.view")] + public async Task Stream() + { + var parameters = await ExtractAllParameters(); + var id = parameters.GetValueOrDefault("id", ""); + + if (string.IsNullOrWhiteSpace(id)) + { + return BadRequest(new { error = "Missing id parameter" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id); + + if (!isExternal) + { + // Chanson locale - relayer vers Subsonic + return await RelayStreamToSubsonic(parameters); + } + + // Chanson externe - vérifier si déjà téléchargée + var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider!, externalId!); + + if (localPath != null && System.IO.File.Exists(localPath)) + { + // Fichier déjà disponible localement + var stream = System.IO.File.OpenRead(localPath); + return File(stream, GetContentType(localPath), enableRangeProcessing: true); + } + + // Télécharger et streamer à la volée + try + { + var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, HttpContext.RequestAborted); + return File(downloadStream, "audio/mpeg", enableRangeProcessing: true); + } + catch (Exception ex) + { + return StatusCode(500, new { error = $"Failed to stream: {ex.Message}" }); + } + } + + /// + /// Endpoint getSong personnalisé - retourne les infos d'une chanson externe si nécessaire + /// + [HttpGet, HttpPost] + [Route("rest/getSong")] + [Route("rest/getSong.view")] + public async Task GetSong() + { + var parameters = await ExtractAllParameters(); + var id = parameters.GetValueOrDefault("id", ""); + var format = parameters.GetValueOrDefault("f", "xml"); + + if (string.IsNullOrWhiteSpace(id)) + { + return CreateSubsonicError(format, 10, "Missing id parameter"); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id); + + if (!isExternal) + { + // Chanson locale - relayer vers Subsonic + var result = await RelayToSubsonic("rest/getSong", parameters); + var contentType = result.ContentType ?? $"application/{format}"; + return File((byte[])result.Body, contentType); + } + + // Chanson externe - récupérer depuis le service de métadonnées + var song = await _metadataService.GetSongAsync(provider!, externalId!); + + if (song == null) + { + return CreateSubsonicError(format, 70, "Song not found"); + } + + return CreateSubsonicSongResponse(format, song); + } + + /// + /// Endpoint getAlbum personnalisé + /// + [HttpGet, HttpPost] + [Route("rest/getAlbum")] + [Route("rest/getAlbum.view")] + public async Task GetAlbum() + { + var parameters = await ExtractAllParameters(); + var id = parameters.GetValueOrDefault("id", ""); + var format = parameters.GetValueOrDefault("f", "xml"); + + if (string.IsNullOrWhiteSpace(id)) + { + return CreateSubsonicError(format, 10, "Missing id parameter"); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id); + + if (!isExternal) + { + // Album local - relayer vers Subsonic + var result = await RelayToSubsonic("rest/getAlbum", parameters); + var contentType = result.ContentType ?? $"application/{format}"; + return File((byte[])result.Body, contentType); + } + + // Album externe - récupérer depuis le service de métadonnées + var album = await _metadataService.GetAlbumAsync(provider!, externalId!); + + if (album == null) + { + return CreateSubsonicError(format, 70, "Album not found"); + } + + return CreateSubsonicAlbumResponse(format, album); + } + + /// + /// Endpoint getCoverArt personnalisé - proxy les covers externes + /// + [HttpGet, HttpPost] + [Route("rest/getCoverArt")] + [Route("rest/getCoverArt.view")] + public async Task GetCoverArt() + { + var parameters = await ExtractAllParameters(); + var id = parameters.GetValueOrDefault("id", ""); + + if (string.IsNullOrWhiteSpace(id)) + { + return NotFound(); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id); + + if (!isExternal) + { + // Cover local - relayer vers Subsonic + try + { + var result = await RelayToSubsonic("rest/getCoverArt", parameters); + var contentType = result.ContentType ?? "image/jpeg"; + return File((byte[])result.Body, contentType); + } + catch + { + return NotFound(); + } + } + + // Cover externe - récupérer l'URL depuis les métadonnées + var song = await _metadataService.GetSongAsync(provider!, externalId!); + if (song?.CoverArtUrl != null) + { + // Proxy l'image + var response = await _httpClient.GetAsync(song.CoverArtUrl); + if (response.IsSuccessStatusCode) + { + var imageBytes = await response.Content.ReadAsByteArrayAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; + return File(imageBytes, contentType); + } + } + + return NotFound(); + } + + #region Helper Methods + + private async Task<(byte[]? Body, string? ContentType, bool Success)> RelayToSubsonicSafe(string endpoint, Dictionary parameters) + { try { var result = await RelayToSubsonic(endpoint, parameters); - var contentType = result.ContentType ?? $"application/{parameters.GetValueOrDefault("f", "xml")}"; - return File((byte[])result.Body, contentType); + return ((byte[])result.Body, result.ContentType, true); } - catch (HttpRequestException ex) + catch { - return BadRequest(new { error = $"Error while calling Subsonic: {ex.Message}" }); + return (null, null, false); } } + + private async Task RelayStreamToSubsonic(Dictionary parameters) + { + try + { + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); + var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + return StatusCode((int)response.StatusCode); + } + + var stream = await response.Content.ReadAsStreamAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; + + return File(stream, contentType, enableRangeProcessing: true); + } + catch (Exception ex) + { + return StatusCode(500, new { error = $"Error streaming from Subsonic: {ex.Message}" }); + } + } + + private IActionResult MergeSearchResults( + (byte[]? Body, string? ContentType, bool Success) subsonicResult, + SearchResult externalResult, + string format) + { + // Créer la réponse fusionnée au format Subsonic + if (format == "json") + { + var response = new + { + subsonicResponse = new + { + status = "ok", + version = "1.16.1", + searchResult3 = new + { + song = externalResult.Songs.Select(s => ConvertSongToSubsonicJson(s)).ToList(), + album = externalResult.Albums.Select(a => ConvertAlbumToSubsonicJson(a)).ToList(), + artist = externalResult.Artists.Select(a => ConvertArtistToSubsonicJson(a)).ToList() + } + } + }; + + // TODO: Fusionner avec les résultats Subsonic si disponibles + + return Ok(response); + } + else + { + // Format XML + var ns = XNamespace.Get("http://subsonic.org/restapi"); + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "ok"), + new XAttribute("version", "1.16.1"), + new XElement(ns + "searchResult3", + externalResult.Artists.Select(a => ConvertArtistToSubsonicXml(a, ns)), + externalResult.Albums.Select(a => ConvertAlbumToSubsonicXml(a, ns)), + externalResult.Songs.Select(s => ConvertSongToSubsonicXml(s, ns)) + ) + ) + ); + + return Content(doc.ToString(), "application/xml"); + } + } + + private object ConvertSongToSubsonicJson(Song song) + { + return new + { + id = song.Id, + title = song.Title, + album = song.Album, + artist = song.Artist, + albumId = song.AlbumId, + artistId = song.ArtistId, + duration = song.Duration ?? 0, + track = song.Track ?? 0, + year = song.Year ?? 0, + coverArt = song.Id, // Utilisé pour getCoverArt + isExternal = !song.IsLocal + }; + } + + private object ConvertAlbumToSubsonicJson(Album album) + { + return new + { + id = album.Id, + name = album.Title, + artist = album.Artist, + artistId = album.ArtistId, + songCount = album.SongCount ?? 0, + year = album.Year ?? 0, + coverArt = album.Id, + isExternal = !album.IsLocal + }; + } + + private object ConvertArtistToSubsonicJson(Artist artist) + { + return new + { + id = artist.Id, + name = artist.Name, + albumCount = artist.AlbumCount ?? 0, + coverArt = artist.Id, + isExternal = !artist.IsLocal + }; + } + + private XElement ConvertSongToSubsonicXml(Song song, XNamespace ns) + { + return new XElement(ns + "song", + new XAttribute("id", song.Id), + new XAttribute("title", song.Title), + new XAttribute("album", song.Album ?? ""), + new XAttribute("artist", song.Artist ?? ""), + new XAttribute("duration", song.Duration ?? 0), + new XAttribute("track", song.Track ?? 0), + new XAttribute("year", song.Year ?? 0), + new XAttribute("coverArt", song.Id), + new XAttribute("isExternal", (!song.IsLocal).ToString().ToLower()) + ); + } + + private XElement ConvertAlbumToSubsonicXml(Album album, XNamespace ns) + { + return new XElement(ns + "album", + new XAttribute("id", album.Id), + new XAttribute("name", album.Title), + new XAttribute("artist", album.Artist ?? ""), + new XAttribute("songCount", album.SongCount ?? 0), + new XAttribute("year", album.Year ?? 0), + new XAttribute("coverArt", album.Id), + new XAttribute("isExternal", (!album.IsLocal).ToString().ToLower()) + ); + } + + private XElement ConvertArtistToSubsonicXml(Artist artist, XNamespace ns) + { + return new XElement(ns + "artist", + new XAttribute("id", artist.Id), + new XAttribute("name", artist.Name), + new XAttribute("albumCount", artist.AlbumCount ?? 0), + new XAttribute("coverArt", artist.Id), + new XAttribute("isExternal", (!artist.IsLocal).ToString().ToLower()) + ); + } + + private IActionResult CreateSubsonicResponse(string format, string elementName, object data) + { + if (format == "json") + { + return Ok(new { subsonicResponse = new { status = "ok", version = "1.16.1" } }); + } + + var ns = XNamespace.Get("http://subsonic.org/restapi"); + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "ok"), + new XAttribute("version", "1.16.1"), + new XElement(ns + elementName) + ) + ); + return Content(doc.ToString(), "application/xml"); + } + + private IActionResult CreateSubsonicError(string format, int code, string message) + { + if (format == "json") + { + return Ok(new + { + subsonicResponse = new + { + status = "failed", + version = "1.16.1", + error = new { code, message } + } + }); + } + + var ns = XNamespace.Get("http://subsonic.org/restapi"); + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "failed"), + new XAttribute("version", "1.16.1"), + new XElement(ns + "error", + new XAttribute("code", code), + new XAttribute("message", message) + ) + ) + ); + return Content(doc.ToString(), "application/xml"); + } + + private IActionResult CreateSubsonicSongResponse(string format, Song song) + { + if (format == "json") + { + return Ok(new + { + subsonicResponse = new + { + status = "ok", + version = "1.16.1", + song = ConvertSongToSubsonicJson(song) + } + }); + } + + var ns = XNamespace.Get("http://subsonic.org/restapi"); + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "ok"), + new XAttribute("version", "1.16.1"), + ConvertSongToSubsonicXml(song, ns) + ) + ); + return Content(doc.ToString(), "application/xml"); + } + + private IActionResult CreateSubsonicAlbumResponse(string format, Album album) + { + if (format == "json") + { + return Ok(new + { + subsonicResponse = new + { + status = "ok", + version = "1.16.1", + album = new + { + id = album.Id, + name = album.Title, + artist = album.Artist, + artistId = album.ArtistId, + songCount = album.SongCount ?? 0, + year = album.Year ?? 0, + coverArt = album.Id, + song = album.Songs.Select(s => ConvertSongToSubsonicJson(s)).ToList() + } + } + }); + } + + var ns = XNamespace.Get("http://subsonic.org/restapi"); + var doc = new XDocument( + new XElement(ns + "subsonic-response", + new XAttribute("status", "ok"), + new XAttribute("version", "1.16.1"), + new XElement(ns + "album", + new XAttribute("id", album.Id), + new XAttribute("name", album.Title), + new XAttribute("artist", album.Artist ?? ""), + new XAttribute("songCount", album.SongCount ?? 0), + new XAttribute("year", album.Year ?? 0), + new XAttribute("coverArt", album.Id), + album.Songs.Select(s => ConvertSongToSubsonicXml(s, ns)) + ) + ) + ); + return Content(doc.ToString(), "application/xml"); + } + + private string GetContentType(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".mp3" => "audio/mpeg", + ".flac" => "audio/flac", + ".ogg" => "audio/ogg", + ".m4a" => "audio/mp4", + ".wav" => "audio/wav", + ".aac" => "audio/aac", + _ => "audio/mpeg" + }; + } + + #endregion + + // Generic endpoint to handle all subsonic API calls + [HttpGet, HttpPost] + [Route("{**endpoint}")] + public async Task GenericEndpoint(string endpoint) + { + var parameters = await ExtractAllParameters(); + try + { + var result = await RelayToSubsonic(endpoint, parameters); + var contentType = result.ContentType ?? $"application/{parameters.GetValueOrDefault("f", "xml")}"; + return File((byte[])result.Body, contentType); + } + catch (HttpRequestException ex) + { + return BadRequest(new { error = $"Error while calling Subsonic: {ex.Message}" }); + } + } } \ No newline at end of file diff --git a/octo-fiesta/Models/MusicModels.cs b/octo-fiesta/Models/MusicModels.cs new file mode 100644 index 0000000..ee82d67 --- /dev/null +++ b/octo-fiesta/Models/MusicModels.cs @@ -0,0 +1,114 @@ +namespace octo_fiesta.Models; + +/// +/// Représente une chanson (locale ou externe) +/// +public class Song +{ + /// + /// ID unique. Pour les chansons externes, préfixé avec "ext-" + provider + "-" + id externe + /// Exemple: "ext-deezer-123456" ou "local-789" + /// + public string Id { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + public string Artist { get; set; } = string.Empty; + public string? ArtistId { get; set; } + public string Album { get; set; } = string.Empty; + public string? AlbumId { get; set; } + public int? Duration { get; set; } // En secondes + public int? Track { get; set; } + public int? Year { get; set; } + public string? Genre { get; set; } + public string? CoverArtUrl { get; set; } + + /// + /// Indique si la chanson est disponible localement ou doit être téléchargée + /// + public bool IsLocal { get; set; } + + /// + /// Provider externe (deezer, spotify, etc.) - null si local + /// + public string? ExternalProvider { get; set; } + + /// + /// ID sur le provider externe (pour le téléchargement) + /// + public string? ExternalId { get; set; } + + /// + /// Chemin du fichier local (si disponible) + /// + public string? LocalPath { get; set; } +} + +/// +/// Représente un artiste +/// +public class Artist +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? ImageUrl { get; set; } + public int? AlbumCount { get; set; } + public bool IsLocal { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } +} + +/// +/// Représente un album +/// +public class Album +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Artist { get; set; } = string.Empty; + public string? ArtistId { get; set; } + public int? Year { get; set; } + public int? SongCount { get; set; } + public string? CoverArtUrl { get; set; } + public string? Genre { get; set; } + public bool IsLocal { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public List Songs { get; set; } = new(); +} + +/// +/// Résultat de recherche combinant résultats locaux et externes +/// +public class SearchResult +{ + public List Songs { get; set; } = new(); + public List Albums { get; set; } = new(); + public List Artists { get; set; } = new(); +} + +/// +/// État du téléchargement d'une chanson +/// +public enum DownloadStatus +{ + NotStarted, + InProgress, + Completed, + Failed +} + +/// +/// Information sur un téléchargement en cours ou terminé +/// +public class DownloadInfo +{ + public string SongId { get; set; } = string.Empty; + public string ExternalId { get; set; } = string.Empty; + public string ExternalProvider { get; set; } = string.Empty; + public DownloadStatus Status { get; set; } + public double Progress { get; set; } // 0.0 à 1.0 + public string? LocalPath { get; set; } + public string? ErrorMessage { get; set; } + public DateTime StartedAt { get; set; } + public DateTime? CompletedAt { get; set; } +} diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 8932722..0ba5e0e 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -1,4 +1,5 @@ using octo_fiesta.Models; +using octo_fiesta.Services; var builder = WebApplication.CreateBuilder(args); @@ -9,9 +10,15 @@ builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +// Configuration builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); +// Services métier +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => @@ -22,22 +29,22 @@ builder.Services.AddCors(options => .WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization"); }); }); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.UseAuthorization(); - -app.UseCors(); - -app.MapControllers(); - + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.UseCors(); + +app.MapControllers(); + app.Run(); \ No newline at end of file diff --git a/octo-fiesta/Services/DeezerMetadataService.cs b/octo-fiesta/Services/DeezerMetadataService.cs new file mode 100644 index 0000000..35a8714 --- /dev/null +++ b/octo-fiesta/Services/DeezerMetadataService.cs @@ -0,0 +1,275 @@ +using octo_fiesta.Models; +using System.Text.Json; + +namespace octo_fiesta.Services; + +/// +/// Implémentation du service de métadonnées utilisant l'API Deezer (gratuite, pas besoin de clé) +/// +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> 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(); + if (result.RootElement.TryGetProperty("data", out var data)) + { + foreach (var track in data.EnumerateArray()) + { + songs.Add(ParseDeezerTrack(track)); + } + } + + return songs; + } + + public async Task> 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(); + if (result.RootElement.TryGetProperty("data", out var data)) + { + foreach (var album in data.EnumerateArray()) + { + albums.Add(ParseDeezerAlbum(album)); + } + } + + return albums; + } + + public async Task> 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(); + if (result.RootElement.TryGetProperty("data", out var data)) + { + foreach (var artist in data.EnumerateArray()) + { + artists.Add(ParseDeezerArtist(artist)); + } + } + + return artists; + } + + public async Task 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 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 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 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> GetArtistAlbumsAsync(string externalProvider, string externalId) + { + if (externalProvider != "deezer") return new List(); + + var url = $"{BaseUrl}/artist/{externalId}/albums"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var albums = new List(); + 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 + }; + } +} diff --git a/octo-fiesta/Services/DeezspotDownloadService.cs b/octo-fiesta/Services/DeezspotDownloadService.cs new file mode 100644 index 0000000..7cb3986 --- /dev/null +++ b/octo-fiesta/Services/DeezspotDownloadService.cs @@ -0,0 +1,244 @@ +using octo_fiesta.Models; +using System.Diagnostics; + +namespace octo_fiesta.Services; + +/// +/// 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 +/// +public class DeezspotDownloadService : IDownloadService +{ + private readonly IConfiguration _configuration; + private readonly ILocalLibraryService _localLibraryService; + private readonly IMusicMetadataService _metadataService; + private readonly ILogger _logger; + private readonly Dictionary _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 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 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 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 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 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(); + } +} diff --git a/octo-fiesta/Services/IDownloadService.cs b/octo-fiesta/Services/IDownloadService.cs new file mode 100644 index 0000000..f6d2afd --- /dev/null +++ b/octo-fiesta/Services/IDownloadService.cs @@ -0,0 +1,37 @@ +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Interface pour le service de téléchargement de musique (Deezspot ou autre) +/// +public interface IDownloadService +{ + /// + /// Télécharge une chanson depuis un provider externe + /// + /// Le provider (deezer, spotify) + /// L'ID sur le provider externe + /// Token d'annulation + /// Le chemin du fichier téléchargé + Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); + + /// + /// Télécharge une chanson et stream le résultat au fur et à mesure + /// + /// Le provider (deezer, spotify) + /// L'ID sur le provider externe + /// Token d'annulation + /// Un stream du fichier audio + Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); + + /// + /// Vérifie si une chanson est en cours de téléchargement + /// + DownloadInfo? GetDownloadStatus(string songId); + + /// + /// Vérifie si le service est correctement configuré et fonctionnel + /// + Task IsAvailableAsync(); +} diff --git a/octo-fiesta/Services/IMusicMetadataService.cs b/octo-fiesta/Services/IMusicMetadataService.cs new file mode 100644 index 0000000..eca9f7c --- /dev/null +++ b/octo-fiesta/Services/IMusicMetadataService.cs @@ -0,0 +1,53 @@ +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Interface pour le service de recherche de métadonnées musicales externes +/// (Deezer API, Spotify API, MusicBrainz, etc.) +/// +public interface IMusicMetadataService +{ + /// + /// Recherche des chansons sur les providers externes + /// + /// Terme de recherche + /// Nombre maximum de résultats + /// Liste des chansons trouvées + Task> SearchSongsAsync(string query, int limit = 20); + + /// + /// Recherche des albums sur les providers externes + /// + Task> SearchAlbumsAsync(string query, int limit = 20); + + /// + /// Recherche des artistes sur les providers externes + /// + Task> SearchArtistsAsync(string query, int limit = 20); + + /// + /// Recherche combinée (chansons, albums, artistes) + /// + Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20); + + /// + /// Récupère les détails d'une chanson externe + /// + Task GetSongAsync(string externalProvider, string externalId); + + /// + /// Récupère les détails d'un album externe avec ses chansons + /// + Task GetAlbumAsync(string externalProvider, string externalId); + + /// + /// Récupère les détails d'un artiste externe + /// + Task GetArtistAsync(string externalProvider, string externalId); + + /// + /// Récupère les albums d'un artiste + /// + Task> GetArtistAlbumsAsync(string externalProvider, string externalId); +} diff --git a/octo-fiesta/Services/LocalLibraryService.cs b/octo-fiesta/Services/LocalLibraryService.cs new file mode 100644 index 0000000..f6b5546 --- /dev/null +++ b/octo-fiesta/Services/LocalLibraryService.cs @@ -0,0 +1,161 @@ +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Interface pour la gestion de la bibliothèque locale de musiques +/// +public interface ILocalLibraryService +{ + /// + /// Vérifie si une chanson externe existe déjà localement + /// + Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Enregistre une chanson téléchargée dans la bibliothèque locale + /// + Task RegisterDownloadedSongAsync(Song song, string localPath); + + /// + /// Récupère le mapping entre ID externe et ID local + /// + Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Parse un ID de chanson pour déterminer s'il est externe ou local + /// + (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); +} + +/// +/// Implémentation du service de bibliothèque locale +/// Utilise un fichier JSON simple pour stocker les mappings (peut être remplacé par une BDD) +/// +public class LocalLibraryService : ILocalLibraryService +{ + private readonly string _mappingFilePath; + private readonly string _downloadDirectory; + private Dictionary? _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 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 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> LoadMappingsAsync() + { + if (_mappings != null) return _mappings; + + if (File.Exists(_mappingFilePath)) + { + var json = await File.ReadAllTextAsync(_mappingFilePath); + _mappings = System.Text.Json.JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + else + { + _mappings = new Dictionary(); + } + + return _mappings; + } + + private async Task SaveMappingsAsync(Dictionary 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; +} + +/// +/// Représente le mapping entre une chanson externe et son fichier local +/// +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; } +} diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 10f68b8..03da041 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Subsonic": { + "Url": "http://localhost:4533" + }, + "Library": { + "DownloadPath": "./downloads" + }, + "Deezspot": { + "ExecutablePath": "" + } }