mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -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,10 +13,22 @@ public class SubsonicController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
public SubsonicController(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> subsonicSettings)
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
|
||||
public SubsonicController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
||||
{
|
||||
@@ -47,11 +58,14 @@ public class SubsonicController : ControllerBase
|
||||
try
|
||||
{
|
||||
var bodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
|
||||
if (bodyParams != null)
|
||||
{
|
||||
foreach (var param in bodyParams)
|
||||
{
|
||||
parameters[param.Key] = param.Value?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
|
||||
@@ -84,6 +98,519 @@ public class SubsonicController : ControllerBase
|
||||
return Ok(new { status });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint search3 personnalisé - fusionne les résultats locaux et externes
|
||||
/// </summary>
|
||||
[HttpGet, HttpPost]
|
||||
[Route("rest/search3")]
|
||||
[Route("rest/search3.view")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint stream personnalisé - télécharge à la volée si nécessaire
|
||||
/// </summary>
|
||||
[HttpGet, HttpPost]
|
||||
[Route("rest/stream")]
|
||||
[Route("rest/stream.view")]
|
||||
public async Task<IActionResult> 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}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint getSong personnalisé - retourne les infos d'une chanson externe si nécessaire
|
||||
/// </summary>
|
||||
[HttpGet, HttpPost]
|
||||
[Route("rest/getSong")]
|
||||
[Route("rest/getSong.view")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint getAlbum personnalisé
|
||||
/// </summary>
|
||||
[HttpGet, HttpPost]
|
||||
[Route("rest/getAlbum")]
|
||||
[Route("rest/getAlbum.view")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint getCoverArt personnalisé - proxy les covers externes
|
||||
/// </summary>
|
||||
[HttpGet, HttpPost]
|
||||
[Route("rest/getCoverArt")]
|
||||
[Route("rest/getCoverArt.view")]
|
||||
public async Task<IActionResult> 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<string, string> parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await RelayToSubsonic(endpoint, parameters);
|
||||
return ((byte[])result.Body, result.ContentType, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> RelayStreamToSubsonic(Dictionary<string, string> 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}")]
|
||||
|
||||
114
octo-fiesta/Models/MusicModels.cs
Normal file
114
octo-fiesta/Models/MusicModels.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
namespace octo_fiesta.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Représente une chanson (locale ou externe)
|
||||
/// </summary>
|
||||
public class Song
|
||||
{
|
||||
/// <summary>
|
||||
/// ID unique. Pour les chansons externes, préfixé avec "ext-" + provider + "-" + id externe
|
||||
/// Exemple: "ext-deezer-123456" ou "local-789"
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Indique si la chanson est disponible localement ou doit être téléchargée
|
||||
/// </summary>
|
||||
public bool IsLocal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider externe (deezer, spotify, etc.) - null si local
|
||||
/// </summary>
|
||||
public string? ExternalProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID sur le provider externe (pour le téléchargement)
|
||||
/// </summary>
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Chemin du fichier local (si disponible)
|
||||
/// </summary>
|
||||
public string? LocalPath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Représente un artiste
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Représente un album
|
||||
/// </summary>
|
||||
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<Song> Songs { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résultat de recherche combinant résultats locaux et externes
|
||||
/// </summary>
|
||||
public class SearchResult
|
||||
{
|
||||
public List<Song> Songs { get; set; } = new();
|
||||
public List<Album> Albums { get; set; } = new();
|
||||
public List<Artist> Artists { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// État du téléchargement d'une chanson
|
||||
/// </summary>
|
||||
public enum DownloadStatus
|
||||
{
|
||||
NotStarted,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information sur un téléchargement en cours ou terminé
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -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<SubsonicSettings>(
|
||||
builder.Configuration.GetSection("Subsonic"));
|
||||
|
||||
// Services métier
|
||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||
builder.Services.AddScoped<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddScoped<IDownloadService, DeezspotDownloadService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
|
||||
275
octo-fiesta/Services/DeezerMetadataService.cs
Normal file
275
octo-fiesta/Services/DeezerMetadataService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
244
octo-fiesta/Services/DeezspotDownloadService.cs
Normal file
244
octo-fiesta/Services/DeezspotDownloadService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
37
octo-fiesta/Services/IDownloadService.cs
Normal file
37
octo-fiesta/Services/IDownloadService.cs
Normal 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();
|
||||
}
|
||||
53
octo-fiesta/Services/IMusicMetadataService.cs
Normal file
53
octo-fiesta/Services/IMusicMetadataService.cs
Normal 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);
|
||||
}
|
||||
161
octo-fiesta/Services/LocalLibraryService.cs
Normal file
161
octo-fiesta/Services/LocalLibraryService.cs
Normal 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; }
|
||||
}
|
||||
@@ -5,5 +5,14 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"Subsonic": {
|
||||
"Url": "http://localhost:4533"
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads"
|
||||
},
|
||||
"Deezspot": {
|
||||
"ExecutablePath": ""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user