feat: add subsonic proxy architecture with external music services

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

View File

@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq; using System.Xml.Linq;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services;
namespace octo_fiesta.Controllers; namespace octo_fiesta.Controllers;
@@ -14,42 +13,57 @@ public class SubsonicController : ControllerBase
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings; 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(); _httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value; _subsonicSettings = subsonicSettings.Value;
_metadataService = metadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url)) if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
{ {
throw new Exception("Error: Environment variable SUBSONIC_URL is not set."); throw new Exception("Error: Environment variable SUBSONIC_URL is not set.");
} }
} }
// Extract all parameters (query + body) // Extract all parameters (query + body)
private async Task<Dictionary<string, string>> ExtractAllParameters() private async Task<Dictionary<string, string>> ExtractAllParameters()
{ {
var parameters = new Dictionary<string, string>(); var parameters = new Dictionary<string, string>();
// Get query parameters // Get query parameters
foreach (var query in Request.Query) foreach (var query in Request.Query)
{ {
parameters[query.Key] = query.Value.ToString(); parameters[query.Key] = query.Value.ToString();
} }
// Get body parameters (JSON) // Get body parameters (JSON)
if (Request.ContentLength > 0 && Request.ContentType?.Contains("application/json") == true) if (Request.ContentLength > 0 && Request.ContentType?.Contains("application/json") == true)
{ {
using var reader = new StreamReader(Request.Body); using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync(); var body = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(body)) if (!string.IsNullOrEmpty(body))
{ {
try try
{ {
var bodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(body); var bodyParams = JsonSerializer.Deserialize<Dictionary<string, object>>(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) catch (JsonException)
@@ -57,22 +71,22 @@ public class SubsonicController : ControllerBase
} }
} }
} }
return parameters; return parameters;
} }
private async Task<(object Body, string? ContentType)> RelayToSubsonic(string endpoint, Dictionary<string, string> parameters) private async Task<(object Body, string? ContentType)> RelayToSubsonic(string endpoint, Dictionary<string, string> parameters)
{ {
var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/{endpoint}?{query}"; var url = $"{_subsonicSettings.Url}/{endpoint}?{query}";
HttpResponseMessage response = await _httpClient.GetAsync(url); HttpResponseMessage response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync(); var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString(); var contentType = response.Content.Headers.ContentType?.ToString();
return (body, contentType); return (body, contentType);
} }
[HttpGet, HttpPost] [HttpGet, HttpPost]
[Route("ping")] [Route("ping")]
public async Task<IActionResult> Ping() public async Task<IActionResult> Ping()
@@ -84,21 +98,534 @@ public class SubsonicController : ControllerBase
return Ok(new { status }); return Ok(new { status });
} }
// Generic endpoint to handle all subsonic API calls /// <summary>
/// Endpoint search3 personnalisé - fusionne les résultats locaux et externes
/// </summary>
[HttpGet, HttpPost] [HttpGet, HttpPost]
[Route("{**endpoint}")] [Route("rest/search3")]
public async Task<IActionResult> GenericEndpoint(string endpoint) [Route("rest/search3.view")]
public async Task<IActionResult> Search3()
{ {
var parameters = await ExtractAllParameters(); 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 try
{ {
var result = await RelayToSubsonic(endpoint, parameters); var result = await RelayToSubsonic(endpoint, parameters);
var contentType = result.ContentType ?? $"application/{parameters.GetValueOrDefault("f", "xml")}"; return ((byte[])result.Body, result.ContentType, true);
return File((byte[])result.Body, contentType);
} }
catch (HttpRequestException ex) catch
{ {
return BadRequest(new { error = $"Error while calling Subsonic: {ex.Message}" }); 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}")]
public async Task<IActionResult> 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}" });
}
}
} }

View 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; }
}

View File

@@ -1,4 +1,5 @@
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -9,9 +10,15 @@ builder.Services.AddHttpClient();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// Configuration
builder.Services.Configure<SubsonicSettings>( builder.Services.Configure<SubsonicSettings>(
builder.Configuration.GetSection("Subsonic")); 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 => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
@@ -22,22 +29,22 @@ builder.Services.AddCors(options =>
.WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization"); .WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization");
}); });
}); });
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseAuthorization(); app.UseAuthorization();
app.UseCors(); app.UseCors();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,5 +5,14 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"Subsonic": {
"Url": "http://localhost:4533"
},
"Library": {
"DownloadPath": "./downloads"
},
"Deezspot": {
"ExecutablePath": ""
}
} }