mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
fix: merge conflicts and upstream changes merged
This commit is contained in:
24
.env.example
24
.env.example
@@ -1,7 +1,7 @@
|
|||||||
# Navidrome/Subsonic server URL
|
# Navidrome/Subsonic server URL
|
||||||
SUBSONIC_URL=http://localhost:4533
|
SUBSONIC_URL=http://localhost:4533
|
||||||
|
|
||||||
# Path where downloaded songs will be stored on the host
|
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
|
||||||
DOWNLOAD_PATH=./downloads
|
DOWNLOAD_PATH=./downloads
|
||||||
|
|
||||||
# Music service to use: Deezer or Qobuz (default: Deezer)
|
# Music service to use: Deezer or Qobuz (default: Deezer)
|
||||||
@@ -34,6 +34,15 @@ QOBUZ_USER_ID=
|
|||||||
QOBUZ_QUALITY=
|
QOBUZ_QUALITY=
|
||||||
|
|
||||||
# ===== GENERAL SETTINGS =====
|
# ===== GENERAL SETTINGS =====
|
||||||
|
# External playlists support (optional, default: true)
|
||||||
|
# When enabled, allows searching and downloading playlists from Deezer/Qobuz
|
||||||
|
# Starring a playlist triggers automatic download of all tracks and creates an M3U file
|
||||||
|
ENABLE_EXTERNAL_PLAYLISTS=true
|
||||||
|
|
||||||
|
# Playlists directory name (optional, default: playlists)
|
||||||
|
# M3U playlist files will be created in {DOWNLOAD_PATH}/{PLAYLISTS_DIRECTORY}/
|
||||||
|
PLAYLISTS_DIRECTORY=playlists
|
||||||
|
|
||||||
# Explicit content filter (optional, default: All)
|
# Explicit content filter (optional, default: All)
|
||||||
# - All: Show all tracks (no filtering)
|
# - All: Show all tracks (no filtering)
|
||||||
# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content
|
# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content
|
||||||
@@ -46,3 +55,16 @@ EXPLICIT_FILTER=All
|
|||||||
# - Album: When playing a track, download the entire album in background
|
# - Album: When playing a track, download the entire album in background
|
||||||
# The played track is downloaded first, remaining tracks are queued
|
# The played track is downloaded first, remaining tracks are queued
|
||||||
DOWNLOAD_MODE=Track
|
DOWNLOAD_MODE=Track
|
||||||
|
|
||||||
|
# Storage mode (optional, default: Permanent)
|
||||||
|
# - Permanent: Files are saved to the library permanently and registered in Navidrome
|
||||||
|
# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS
|
||||||
|
# Not registered in Navidrome, ideal for streaming without library bloat
|
||||||
|
# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable
|
||||||
|
STORAGE_MODE=Permanent
|
||||||
|
|
||||||
|
# Cache duration in hours (optional, default: 1)
|
||||||
|
# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache
|
||||||
|
# Based on last access time (updated each time the file is streamed)
|
||||||
|
# Cache location: /tmp/octo-fiesta-cache (or $TMPDIR/octo-fiesta-cache if TMPDIR is set)
|
||||||
|
CACHE_DURATION_HOURS=1
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -12,6 +12,7 @@ The name was randomly generated by GitHub when creating the repository. We found
|
|||||||
- **Transparent Proxy**: Acts as a middleware between Subsonic clients (like Aonsoku, Sublime Music, etc.) and your Navidrome server
|
- **Transparent Proxy**: Acts as a middleware between Subsonic clients (like Aonsoku, Sublime Music, etc.) and your Navidrome server
|
||||||
- **Seamless Integration**: Automatically searches and streams music from your configured provider when not available locally
|
- **Seamless Integration**: Automatically searches and streams music from your configured provider when not available locally
|
||||||
- **Automatic Downloads**: Songs are downloaded on-the-fly and cached for future use
|
- **Automatic Downloads**: Songs are downloaded on-the-fly and cached for future use
|
||||||
|
- **External Playlist Support**: Search, discover, and download playlists from Deezer and Qobuz with automatic M3U generation
|
||||||
- **Hi-Res Audio Support**: Qobuz provider supports up to 24-bit/192kHz FLAC quality
|
- **Hi-Res Audio Support**: Qobuz provider supports up to 24-bit/192kHz FLAC quality
|
||||||
- **Full Metadata Embedding**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and embedded cover art
|
- **Full Metadata Embedding**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and embedded cover art
|
||||||
- **Organized Library**: Downloads are saved in a clean `Artist/Album/Track` folder structure
|
- **Organized Library**: Downloads are saved in a clean `Artist/Album/Track` folder structure
|
||||||
@@ -33,6 +34,10 @@ The name was randomly generated by GitHub when creating the repository. We found
|
|||||||
- [Tempus](https://github.com/eddyizm/tempus)
|
- [Tempus](https://github.com/eddyizm/tempus)
|
||||||
- [Substreamer](https://substreamerapp.com/)
|
- [Substreamer](https://substreamerapp.com/)
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
- [Narjo](https://www.reddit.com/r/NarjoApp/)
|
||||||
|
|
||||||
> **Want to improve client compatibility?** Pull requests are welcome!
|
> **Want to improve client compatibility?** Pull requests are welcome!
|
||||||
|
|
||||||
### Incompatible Clients
|
### Incompatible Clients
|
||||||
@@ -76,6 +81,10 @@ The easiest way to run Octo-Fiesta is with Docker Compose.
|
|||||||
# Music service provider (Deezer or Qobuz)
|
# Music service provider (Deezer or Qobuz)
|
||||||
MUSIC_SERVICE=Qobuz
|
MUSIC_SERVICE=Qobuz
|
||||||
|
|
||||||
|
# === External Playlists (optional) ===
|
||||||
|
ENABLE_EXTERNAL_PLAYLISTS=true # Enable/disable playlist support (default: true)
|
||||||
|
PLAYLISTS_DIRECTORY=playlists # Directory name for M3U files (default: playlists)
|
||||||
|
|
||||||
# === Qobuz Configuration (if using Qobuz) ===
|
# === Qobuz Configuration (if using Qobuz) ===
|
||||||
QOBUZ_USER_AUTH_TOKEN=your-qobuz-token
|
QOBUZ_USER_AUTH_TOKEN=your-qobuz-token
|
||||||
QOBUZ_USER_ID=your-qobuz-user-id
|
QOBUZ_USER_ID=your-qobuz-user-id
|
||||||
@@ -125,6 +134,29 @@ The easiest way to run Octo-Fiesta is with Docker Compose.
|
|||||||
| `Qobuz:UserId` | Your Qobuz User ID (required if using Qobuz) |
|
| `Qobuz:UserId` | Your Qobuz User ID (required if using Qobuz) |
|
||||||
| `Qobuz:Quality` | Preferred audio quality: `FLAC`, `FLAC_24_HIGH`, `FLAC_24_LOW`, `FLAC_16`, `MP3_320`. If not specified, the highest available quality will be used |
|
| `Qobuz:Quality` | Preferred audio quality: `FLAC`, `FLAC_24_HIGH`, `FLAC_24_LOW`, `FLAC_16`, `MP3_320`. If not specified, the highest available quality will be used |
|
||||||
|
|
||||||
|
### External Playlists
|
||||||
|
|
||||||
|
Octo-Fiesta supports discovering and downloading playlists from your streaming providers (Deezer and Qobuz).
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `Subsonic:EnableExternalPlaylists` | Enable/disable external playlist support (default: `true`) |
|
||||||
|
| `Subsonic:PlaylistsDirectory` | Directory name where M3U playlist files are created (default: `playlists`) |
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Search for playlists from Deezer or Qobuz using the global search in your Subsonic client
|
||||||
|
2. When you "star" (favorite) a playlist, Octo-Fiesta automatically downloads all tracks
|
||||||
|
3. An M3U playlist file is created in `{DownloadPath}/playlists/` with relative paths to downloaded tracks
|
||||||
|
4. Individual tracks are added to the M3U as they are played or downloaded
|
||||||
|
|
||||||
|
**Environment variable:**
|
||||||
|
```bash
|
||||||
|
# To disable playlists
|
||||||
|
Subsonic__EnableExternalPlaylists=false
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
|
||||||
|
|
||||||
### Getting Credentials
|
### Getting Credentials
|
||||||
|
|
||||||
#### Deezer ARL Token
|
#### Deezer ARL Token
|
||||||
@@ -217,12 +249,13 @@ The proxy implements the Subsonic API and adds transparent streaming provider in
|
|||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `GET /rest/search3` | Merged search results from Navidrome + streaming provider |
|
| `GET /rest/search3` | Merged search results from Navidrome + streaming provider (including playlists) |
|
||||||
| `GET /rest/stream` | Streams audio, downloading from provider if needed |
|
| `GET /rest/stream` | Streams audio, downloading from provider if needed |
|
||||||
| `GET /rest/getSong` | Returns song details (local or from provider) |
|
| `GET /rest/getSong` | Returns song details (local or from provider) |
|
||||||
| `GET /rest/getAlbum` | Returns album with tracks from both sources |
|
| `GET /rest/getAlbum` | Returns album with tracks from both sources |
|
||||||
| `GET /rest/getArtist` | Returns artist with albums from both sources |
|
| `GET /rest/getArtist` | Returns artist with albums from both sources |
|
||||||
| `GET /rest/getCoverArt` | Proxies cover art for external content |
|
| `GET /rest/getCoverArt` | Proxies cover art for external content |
|
||||||
|
| `GET /rest/star` | Stars items; triggers automatic playlist download for external playlists |
|
||||||
|
|
||||||
All other Subsonic API endpoints are passed through to Navidrome unchanged.
|
All other Subsonic API endpoints are passed through to Navidrome unchanged.
|
||||||
|
|
||||||
@@ -250,10 +283,16 @@ downloads/
|
|||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── Another Album/
|
│ └── Another Album/
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── Another Artist/
|
├── Another Artist/
|
||||||
|
│ └── ...
|
||||||
|
└── playlists/
|
||||||
|
├── My Favorite Songs.m3u
|
||||||
|
├── Chill Vibes.m3u
|
||||||
└── ...
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
|
||||||
|
|
||||||
## Metadata Embedding
|
## Metadata Embedding
|
||||||
|
|
||||||
Downloaded files include:
|
Downloaded files include:
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ public class DeezerDownloadServiceTests : IDisposable
|
|||||||
Quality = null
|
Quality = null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var serviceProviderMock = new Mock<IServiceProvider>();
|
||||||
|
serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService)))
|
||||||
|
.Returns(null);
|
||||||
|
|
||||||
return new DeezerDownloadService(
|
return new DeezerDownloadService(
|
||||||
_httpClientFactoryMock.Object,
|
_httpClientFactoryMock.Object,
|
||||||
config,
|
config,
|
||||||
@@ -90,6 +94,7 @@ public class DeezerDownloadServiceTests : IDisposable
|
|||||||
_metadataServiceMock.Object,
|
_metadataServiceMock.Object,
|
||||||
subsonicSettings,
|
subsonicSettings,
|
||||||
deezerSettings,
|
deezerSettings,
|
||||||
|
serviceProviderMock.Object,
|
||||||
_loggerMock.Object);
|
_loggerMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -580,4 +580,226 @@ public class DeezerMetadataServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Playlist Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_ReturnsListOfPlaylists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
data = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = 12345,
|
||||||
|
title = "Chill Vibes",
|
||||||
|
nb_tracks = 50,
|
||||||
|
picture_medium = "https://example.com/playlist1.jpg",
|
||||||
|
user = new { name = "Test User" }
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = 67890,
|
||||||
|
title = "Workout Mix",
|
||||||
|
nb_tracks = 30,
|
||||||
|
picture_medium = "https://example.com/playlist2.jpg",
|
||||||
|
user = new { name = "Gym Buddy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("chill");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal("Chill Vibes", result[0].Name);
|
||||||
|
Assert.Equal(50, result[0].TrackCount);
|
||||||
|
Assert.Equal("pl-deezer-12345", result[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_WithLimit_RespectsLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
data = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = 12345,
|
||||||
|
title = "Playlist 1",
|
||||||
|
nb_tracks = 10,
|
||||||
|
picture_medium = "https://example.com/p1.jpg",
|
||||||
|
user = new { name = "User 1" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("test", 1);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
data = new object[] { }
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("nonexistent");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
id = 12345,
|
||||||
|
title = "Best Of Jazz",
|
||||||
|
description = "The best jazz tracks",
|
||||||
|
nb_tracks = 100,
|
||||||
|
picture_medium = "https://example.com/jazz.jpg",
|
||||||
|
user = new { name = "Jazz Lover" }
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistAsync("deezer", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Best Of Jazz", result.Name);
|
||||||
|
Assert.Equal(100, result.TrackCount);
|
||||||
|
Assert.Equal("pl-deezer-12345", result.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistAsync("qobuz", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_ReturnsListOfSongs()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
tracks = new
|
||||||
|
{
|
||||||
|
data = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = 111,
|
||||||
|
title = "Track 1",
|
||||||
|
duration = 200,
|
||||||
|
track_position = 1,
|
||||||
|
disk_number = 1,
|
||||||
|
artist = new
|
||||||
|
{
|
||||||
|
id = 999,
|
||||||
|
name = "Artist A"
|
||||||
|
},
|
||||||
|
album = new
|
||||||
|
{
|
||||||
|
id = 888,
|
||||||
|
title = "Album X",
|
||||||
|
release_date = "2020-01-15",
|
||||||
|
cover_medium = "https://example.com/cover.jpg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
id = 222,
|
||||||
|
title = "Track 2",
|
||||||
|
duration = 180,
|
||||||
|
track_position = 2,
|
||||||
|
disk_number = 1,
|
||||||
|
artist = new
|
||||||
|
{
|
||||||
|
id = 777,
|
||||||
|
name = "Artist B"
|
||||||
|
},
|
||||||
|
album = new
|
||||||
|
{
|
||||||
|
id = 666,
|
||||||
|
title = "Album Y",
|
||||||
|
release_date = "2021-05-20",
|
||||||
|
cover_medium = "https://example.com/cover2.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal("Track 1", result[0].Title);
|
||||||
|
Assert.Equal("Artist A", result[0].Artist);
|
||||||
|
Assert.Equal("ext-deezer-song-111", result[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("qobuz", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WithEmptyPlaylist_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var deezerResponse = new
|
||||||
|
{
|
||||||
|
tracks = new
|
||||||
|
{
|
||||||
|
data = new object[] { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(deezerResponse));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
375
octo-fiesta.Tests/PlaylistIdHelperTests.cs
Normal file
375
octo-fiesta.Tests/PlaylistIdHelperTests.cs
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
using octo_fiesta.Services.Common;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace octo_fiesta.Tests;
|
||||||
|
|
||||||
|
public class PlaylistIdHelperTests
|
||||||
|
{
|
||||||
|
#region IsExternalPlaylist Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithValidPlaylistId_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-deezer-123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithValidQobuzPlaylistId_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-qobuz-789012";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithUpperCasePrefix_ReturnsTrue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "PL-deezer-123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithRegularAlbumId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "ext-deezer-album-123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithNullId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string? id = null;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithEmptyString_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsExternalPlaylist_WithRandomString_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "random-string-123";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.IsExternalPlaylist(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ParsePlaylistId Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithValidDeezerPlaylistId_ReturnsProviderAndExternalId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-deezer-123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("deezer", provider);
|
||||||
|
Assert.Equal("123456", externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithValidQobuzPlaylistId_ReturnsProviderAndExternalId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-qobuz-789012";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("qobuz", provider);
|
||||||
|
Assert.Equal("789012", externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithExternalIdContainingDashes_ParsesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-deezer-abc-def-123";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("deezer", provider);
|
||||||
|
Assert.Equal("abc-def-123", externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithInvalidFormatNoProvider_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-123456";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
|
||||||
|
Assert.Contains("Invalid playlist ID format", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithNonPlaylistId_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "ext-deezer-album-123456";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
|
||||||
|
Assert.Contains("Invalid playlist ID format", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithNullId_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string? id = null;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithEmptyString_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlaylistId_WithOnlyPrefix_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = "pl-";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.ParsePlaylistId(id));
|
||||||
|
Assert.Contains("Invalid playlist ID format", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region CreatePlaylistId Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithValidDeezerProviderAndId_ReturnsCorrectFormat()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "deezer";
|
||||||
|
var externalId = "123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("pl-deezer-123456", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithValidQobuzProviderAndId_ReturnsCorrectFormat()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "qobuz";
|
||||||
|
var externalId = "789012";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("pl-qobuz-789012", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithUpperCaseProvider_ConvertsToLowerCase()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "DEEZER";
|
||||||
|
var externalId = "123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("pl-deezer-123456", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithMixedCaseProvider_ConvertsToLowerCase()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "DeEzEr";
|
||||||
|
var externalId = "123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("pl-deezer-123456", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithExternalIdContainingDashes_PreservesDashes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "deezer";
|
||||||
|
var externalId = "abc-def-123";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("pl-deezer-abc-def-123", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithNullProvider_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string? provider = null;
|
||||||
|
var externalId = "123456";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider!, externalId));
|
||||||
|
Assert.Contains("Provider cannot be null or empty", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithEmptyProvider_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "";
|
||||||
|
var externalId = "123456";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId));
|
||||||
|
Assert.Contains("Provider cannot be null or empty", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithNullExternalId_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "deezer";
|
||||||
|
string? externalId = null;
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId!));
|
||||||
|
Assert.Contains("External ID cannot be null or empty", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatePlaylistId_WithEmptyExternalId_ThrowsArgumentException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var provider = "deezer";
|
||||||
|
var externalId = "";
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
var exception = Assert.Throws<ArgumentException>(() => PlaylistIdHelper.CreatePlaylistId(provider, externalId));
|
||||||
|
Assert.Contains("External ID cannot be null or empty", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Round-Trip Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_CreateAndParse_ReturnsOriginalValues()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var originalProvider = "deezer";
|
||||||
|
var originalExternalId = "123456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
|
||||||
|
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(originalProvider, parsedProvider);
|
||||||
|
Assert.Equal(originalExternalId, parsedExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_CreateWithUpperCaseAndParse_ReturnsLowerCaseProvider()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var originalProvider = "QOBUZ";
|
||||||
|
var originalExternalId = "789012";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
|
||||||
|
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("qobuz", parsedProvider); // Converted to lowercase
|
||||||
|
Assert.Equal(originalExternalId, parsedExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoundTrip_WithComplexExternalId_PreservesValue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var originalProvider = "deezer";
|
||||||
|
var originalExternalId = "abc-123-def-456";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var playlistId = PlaylistIdHelper.CreatePlaylistId(originalProvider, originalExternalId);
|
||||||
|
var (parsedProvider, parsedExternalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(originalProvider, parsedProvider);
|
||||||
|
Assert.Equal(originalExternalId, parsedExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -86,6 +86,10 @@ public class QobuzDownloadServiceTests : IDisposable
|
|||||||
Quality = quality
|
Quality = quality
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var serviceProviderMock = new Mock<IServiceProvider>();
|
||||||
|
serviceProviderMock.Setup(sp => sp.GetService(typeof(octo_fiesta.Services.Subsonic.PlaylistSyncService)))
|
||||||
|
.Returns(null);
|
||||||
|
|
||||||
return new QobuzDownloadService(
|
return new QobuzDownloadService(
|
||||||
_httpClientFactoryMock.Object,
|
_httpClientFactoryMock.Object,
|
||||||
config,
|
config,
|
||||||
@@ -94,6 +98,7 @@ public class QobuzDownloadServiceTests : IDisposable
|
|||||||
_bundleService,
|
_bundleService,
|
||||||
subsonicSettings,
|
subsonicSettings,
|
||||||
qobuzSettings,
|
qobuzSettings,
|
||||||
|
serviceProviderMock.Object,
|
||||||
_loggerMock.Object);
|
_loggerMock.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
662
octo-fiesta.Tests/QobuzMetadataServiceTests.cs
Normal file
662
octo-fiesta.Tests/QobuzMetadataServiceTests.cs
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
using octo_fiesta.Services.Qobuz;
|
||||||
|
using octo_fiesta.Models.Domain;
|
||||||
|
using octo_fiesta.Models.Settings;
|
||||||
|
using octo_fiesta.Models.Subsonic;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Moq.Protected;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace octo_fiesta.Tests;
|
||||||
|
|
||||||
|
public class QobuzMetadataServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||||
|
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||||
|
private readonly Mock<QobuzBundleService> _bundleServiceMock;
|
||||||
|
private readonly Mock<ILogger<QobuzMetadataService>> _loggerMock;
|
||||||
|
private readonly QobuzMetadataService _service;
|
||||||
|
|
||||||
|
public QobuzMetadataServiceTests()
|
||||||
|
{
|
||||||
|
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||||
|
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||||
|
|
||||||
|
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||||
|
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
|
// Mock QobuzBundleService (methods are now virtual so can be mocked)
|
||||||
|
var bundleHttpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||||
|
bundleHttpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
var bundleLogger = Mock.Of<ILogger<QobuzBundleService>>();
|
||||||
|
_bundleServiceMock = new Mock<QobuzBundleService>(bundleHttpClientFactoryMock.Object, bundleLogger) { CallBase = false };
|
||||||
|
_bundleServiceMock.Setup(b => b.GetAppIdAsync()).ReturnsAsync("fake-app-id-12345");
|
||||||
|
_bundleServiceMock.Setup(b => b.GetSecretsAsync()).ReturnsAsync(new List<string> { "fake-secret" });
|
||||||
|
_bundleServiceMock.Setup(b => b.GetSecretAsync(It.IsAny<int>())).ReturnsAsync("fake-secret");
|
||||||
|
|
||||||
|
_loggerMock = new Mock<ILogger<QobuzMetadataService>>();
|
||||||
|
|
||||||
|
var subsonicSettings = Options.Create(new SubsonicSettings());
|
||||||
|
var qobuzSettings = Options.Create(new QobuzSettings
|
||||||
|
{
|
||||||
|
UserAuthToken = "fake-user-auth-token",
|
||||||
|
UserId = "8807208"
|
||||||
|
});
|
||||||
|
|
||||||
|
_service = new QobuzMetadataService(
|
||||||
|
_httpClientFactoryMock.Object,
|
||||||
|
subsonicSettings,
|
||||||
|
qobuzSettings,
|
||||||
|
_bundleServiceMock.Object,
|
||||||
|
_loggerMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region SearchPlaylistsAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_WithValidQuery_ReturnsPlaylists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""playlists"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 1578664,
|
||||||
|
""name"": ""Jazz Classics"",
|
||||||
|
""description"": ""Best of classic jazz music"",
|
||||||
|
""tracks_count"": 50,
|
||||||
|
""duration"": 12000,
|
||||||
|
""owner"": {
|
||||||
|
""name"": ""Qobuz Editorial""
|
||||||
|
},
|
||||||
|
""created_at"": 1609459200,
|
||||||
|
""images300"": [""https://example.com/cover.jpg""]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("jazz", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Jazz Classics", result[0].Name);
|
||||||
|
Assert.Equal("Best of classic jazz music", result[0].Description);
|
||||||
|
Assert.Equal(50, result[0].TrackCount);
|
||||||
|
Assert.Equal(12000, result[0].Duration);
|
||||||
|
Assert.Equal("qobuz", result[0].Provider);
|
||||||
|
Assert.Equal("1578664", result[0].ExternalId);
|
||||||
|
Assert.Equal("pl-qobuz-1578664", result[0].Id);
|
||||||
|
Assert.Equal("Qobuz Editorial", result[0].CuratorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_WithEmptyResults_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""playlists"": {
|
||||||
|
""items"": []
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("nonexistent", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchPlaylistsAsync_WhenHttpFails_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.InternalServerError
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchPlaylistsAsync("jazz", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetPlaylistAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistAsync_WithValidId_ReturnsPlaylist()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""id"": 1578664,
|
||||||
|
""name"": ""Best Of Jazz"",
|
||||||
|
""description"": ""Top jazz tracks"",
|
||||||
|
""tracks_count"": 100,
|
||||||
|
""duration"": 24000,
|
||||||
|
""owner"": {
|
||||||
|
""name"": ""Qobuz Editor""
|
||||||
|
},
|
||||||
|
""created_at"": 1609459200,
|
||||||
|
""image_rectangle"": [""https://example.com/cover-large.jpg""]
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistAsync("qobuz", "1578664");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Best Of Jazz", result.Name);
|
||||||
|
Assert.Equal("Top jazz tracks", result.Description);
|
||||||
|
Assert.Equal(100, result.TrackCount);
|
||||||
|
Assert.Equal(24000, result.Duration);
|
||||||
|
Assert.Equal("pl-qobuz-1578664", result.Id);
|
||||||
|
Assert.Equal("Qobuz Editor", result.CuratorName);
|
||||||
|
Assert.Equal("https://example.com/cover-large.jpg", result.CoverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistAsync_WithWrongProvider_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistAsync("deezer", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetPlaylistTracksAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WithValidId_ReturnsTracks()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""id"": 1578664,
|
||||||
|
""name"": ""My Jazz Playlist"",
|
||||||
|
""tracks"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 123456789,
|
||||||
|
""title"": ""Take Five"",
|
||||||
|
""duration"": 324,
|
||||||
|
""track_number"": 1,
|
||||||
|
""media_number"": 1,
|
||||||
|
""performer"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""image"": {
|
||||||
|
""thumbnail"": ""https://example.com/time-out.jpg""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": 987654321,
|
||||||
|
""title"": ""So What"",
|
||||||
|
""duration"": 562,
|
||||||
|
""track_number"": 2,
|
||||||
|
""media_number"": 1,
|
||||||
|
""performer"": {
|
||||||
|
""id"": 333,
|
||||||
|
""name"": ""Miles Davis""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 444,
|
||||||
|
""title"": ""Kind of Blue"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 333,
|
||||||
|
""name"": ""Miles Davis""
|
||||||
|
},
|
||||||
|
""image"": {
|
||||||
|
""thumbnail"": ""https://example.com/kind-of-blue.jpg""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
|
||||||
|
// First track
|
||||||
|
Assert.Equal("Take Five", result[0].Title);
|
||||||
|
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
|
||||||
|
Assert.Equal("My Jazz Playlist", result[0].Album); // Album should be playlist name
|
||||||
|
Assert.Equal(1, result[0].Track); // Track index starts at 1
|
||||||
|
Assert.Equal("ext-qobuz-song-123456789", result[0].Id);
|
||||||
|
Assert.Equal("qobuz", result[0].ExternalProvider);
|
||||||
|
Assert.Equal("123456789", result[0].ExternalId);
|
||||||
|
|
||||||
|
// Second track
|
||||||
|
Assert.Equal("So What", result[1].Title);
|
||||||
|
Assert.Equal("Miles Davis", result[1].Artist);
|
||||||
|
Assert.Equal("My Jazz Playlist", result[1].Album); // Album should be playlist name
|
||||||
|
Assert.Equal(2, result[1].Track); // Track index increments
|
||||||
|
Assert.Equal("ext-qobuz-song-987654321", result[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WithWrongProvider_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("deezer", "12345");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WhenHttpFails_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.NotFound
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("qobuz", "999999");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlaylistTracksAsync_WithMissingPlaylistName_UsesDefaultName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""id"": 1578664,
|
||||||
|
""tracks"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 123,
|
||||||
|
""title"": ""Test Track"",
|
||||||
|
""performer"": {
|
||||||
|
""id"": 1,
|
||||||
|
""name"": ""Test Artist""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 2,
|
||||||
|
""title"": ""Test Album"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 1,
|
||||||
|
""name"": ""Test Artist""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetPlaylistTracksAsync("qobuz", "1578664");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Unknown Playlist", result[0].Album);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region SearchSongsAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchSongsAsync_WithValidQuery_ReturnsSongs()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""tracks"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 123456789,
|
||||||
|
""title"": ""Take Five"",
|
||||||
|
""duration"": 324,
|
||||||
|
""track_number"": 1,
|
||||||
|
""performer"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchSongsAsync("Take Five", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Take Five", result[0].Title);
|
||||||
|
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region SearchAlbumsAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAlbumsAsync_WithValidQuery_ReturnsAlbums()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""albums"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""tracks_count"": 7,
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""release_date_original"": ""1959-12-14""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.SearchAlbumsAsync("Time Out", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Time Out", result[0].Title);
|
||||||
|
Assert.Equal("Dave Brubeck Quartet", result[0].Artist);
|
||||||
|
Assert.Equal(1959, result[0].Year);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetSongAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSongAsync_WithValidId_ReturnsSong()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""id"": 123456789,
|
||||||
|
""title"": ""Take Five"",
|
||||||
|
""duration"": 324,
|
||||||
|
""track_number"": 1,
|
||||||
|
""isrc"": ""USCO10300456"",
|
||||||
|
""copyright"": ""(P) 1959 Columbia Records"",
|
||||||
|
""performer"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""composer"": {
|
||||||
|
""id"": 999,
|
||||||
|
""name"": ""Paul Desmond""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""tracks_count"": 7,
|
||||||
|
""release_date_original"": ""1959-12-14"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""genres_list"": [""Jazz"", ""Jazz→Cool Jazz""]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSongAsync("qobuz", "123456789");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Take Five", result.Title);
|
||||||
|
Assert.Equal("Dave Brubeck Quartet", result.Artist);
|
||||||
|
Assert.Equal("Time Out", result.Album);
|
||||||
|
Assert.Equal("USCO10300456", result.Isrc);
|
||||||
|
Assert.Equal("℗ 1959 Columbia Records", result.Copyright);
|
||||||
|
Assert.Equal(1959, result.Year);
|
||||||
|
Assert.Equal("1959-12-14", result.ReleaseDate);
|
||||||
|
Assert.Contains("Paul Desmond", result.Contributors);
|
||||||
|
Assert.Equal("Jazz, Cool Jazz", result.Genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSongAsync_WithWrongProvider_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSongAsync("deezer", "123456789");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetAlbumAsync Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAlbumAsync_WithValidId_ReturnsAlbumWithTracks()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockResponse = new HttpResponseMessage
|
||||||
|
{
|
||||||
|
StatusCode = HttpStatusCode.OK,
|
||||||
|
Content = new StringContent(@"{
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""tracks_count"": 2,
|
||||||
|
""release_date_original"": ""1959-12-14"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""genres_list"": [""Jazz""],
|
||||||
|
""tracks"": {
|
||||||
|
""items"": [
|
||||||
|
{
|
||||||
|
""id"": 1,
|
||||||
|
""title"": ""Blue Rondo à la Turk"",
|
||||||
|
""track_number"": 1,
|
||||||
|
""performer"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
""id"": 2,
|
||||||
|
""title"": ""Take Five"",
|
||||||
|
""track_number"": 2,
|
||||||
|
""performer"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
},
|
||||||
|
""album"": {
|
||||||
|
""id"": 222,
|
||||||
|
""title"": ""Time Out"",
|
||||||
|
""artist"": {
|
||||||
|
""id"": 111,
|
||||||
|
""name"": ""Dave Brubeck Quartet""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}")
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpMessageHandlerMock.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(mockResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetAlbumAsync("qobuz", "222");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Time Out", result.Title);
|
||||||
|
Assert.Equal("Dave Brubeck Quartet", result.Artist);
|
||||||
|
Assert.Equal(1959, result.Year);
|
||||||
|
Assert.Equal(2, result.Songs.Count);
|
||||||
|
Assert.Equal("Blue Rondo à la Turk", result.Songs[0].Title);
|
||||||
|
Assert.Equal("Take Five", result.Songs[1].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAlbumAsync_WithWrongProvider_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetAlbumAsync("deezer", "222");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using octo_fiesta.Models.Domain;
|
using octo_fiesta.Models.Domain;
|
||||||
using octo_fiesta.Models.Search;
|
using octo_fiesta.Models.Search;
|
||||||
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services.Subsonic;
|
using octo_fiesta.Services.Subsonic;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -187,39 +188,12 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
localSongs, new List<object>(), new List<object>(), externalResult, true);
|
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, mergedSongs.Count);
|
Assert.Equal(2, mergedSongs.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MergeSearchResults_Json_DeduplicatesArtists()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var localArtists = new List<object>
|
|
||||||
{
|
|
||||||
new Dictionary<string, object> { ["id"] = "local1", ["name"] = "Test Artist" }
|
|
||||||
};
|
|
||||||
var externalResult = new SearchResult
|
|
||||||
{
|
|
||||||
Songs = new List<Song>(),
|
|
||||||
Albums = new List<Album>(),
|
|
||||||
Artists = new List<Artist>
|
|
||||||
{
|
|
||||||
new Artist { Id = "ext1", Name = "Test Artist" }, // Same name - should be filtered
|
|
||||||
new Artist { Id = "ext2", Name = "Different Artist" } // Different name - should be included
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
|
||||||
new List<object>(), new List<object>(), localArtists, externalResult, true);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MergeSearchResults_Json_CaseInsensitiveDeduplication()
|
public void MergeSearchResults_Json_CaseInsensitiveDeduplication()
|
||||||
{
|
{
|
||||||
@@ -240,7 +214,7 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
new List<object>(), new List<object>(), localArtists, externalResult, true);
|
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(mergedArtists); // Only the local artist
|
Assert.Single(mergedArtists); // Only the local artist
|
||||||
@@ -267,7 +241,7 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
localSongs, new List<object>(), new List<object>(), externalResult, false);
|
localSongs, new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), false);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, mergedSongs.Count);
|
Assert.Equal(2, mergedSongs.Count);
|
||||||
@@ -294,7 +268,7 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
new List<object>(), new List<object>(), localArtists, externalResult, false);
|
new List<object>(), new List<object>(), localArtists, externalResult, new List<ExternalPlaylist>(), false);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
|
Assert.Equal(2, mergedArtists.Count); // 1 local + 1 external (duplicate filtered)
|
||||||
@@ -313,7 +287,7 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
new List<object>(), new List<object>(), new List<object>(), externalResult, true);
|
new List<object>(), new List<object>(), new List<object>(), externalResult, new List<ExternalPlaylist>(), true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(mergedSongs);
|
Assert.Single(mergedSongs);
|
||||||
@@ -337,7 +311,7 @@ public class SubsonicModelMapperTests
|
|||||||
|
|
||||||
// Act
|
// Act
|
||||||
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
var (mergedSongs, mergedAlbums, mergedArtists) = _mapper.MergeSearchResults(
|
||||||
localSongs, localAlbums, localArtists, externalResult, true);
|
localSongs, localAlbums, localArtists, externalResult, new List<ExternalPlaylist>(), true);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Single(mergedSongs);
|
Assert.Single(mergedSongs);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using octo_fiesta.Models.Download;
|
|||||||
using octo_fiesta.Models.Search;
|
using octo_fiesta.Models.Search;
|
||||||
using octo_fiesta.Models.Subsonic;
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services;
|
using octo_fiesta.Services;
|
||||||
|
using octo_fiesta.Services.Common;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
using octo_fiesta.Services.Subsonic;
|
using octo_fiesta.Services.Subsonic;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ public class SubsonicController : ControllerBase
|
|||||||
private readonly SubsonicResponseBuilder _responseBuilder;
|
private readonly SubsonicResponseBuilder _responseBuilder;
|
||||||
private readonly SubsonicModelMapper _modelMapper;
|
private readonly SubsonicModelMapper _modelMapper;
|
||||||
private readonly SubsonicProxyService _proxyService;
|
private readonly SubsonicProxyService _proxyService;
|
||||||
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly ILogger<SubsonicController> _logger;
|
private readonly ILogger<SubsonicController> _logger;
|
||||||
|
|
||||||
public SubsonicController(
|
public SubsonicController(
|
||||||
@@ -37,7 +39,8 @@ public class SubsonicController : ControllerBase
|
|||||||
SubsonicResponseBuilder responseBuilder,
|
SubsonicResponseBuilder responseBuilder,
|
||||||
SubsonicModelMapper modelMapper,
|
SubsonicModelMapper modelMapper,
|
||||||
SubsonicProxyService proxyService,
|
SubsonicProxyService proxyService,
|
||||||
ILogger<SubsonicController> logger)
|
ILogger<SubsonicController> logger,
|
||||||
|
PlaylistSyncService? playlistSyncService = null)
|
||||||
{
|
{
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
@@ -47,6 +50,7 @@ public class SubsonicController : ControllerBase
|
|||||||
_responseBuilder = responseBuilder;
|
_responseBuilder = responseBuilder;
|
||||||
_modelMapper = modelMapper;
|
_modelMapper = modelMapper;
|
||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
|
_playlistSyncService = playlistSyncService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
||||||
@@ -96,13 +100,19 @@ public class SubsonicController : ControllerBase
|
|||||||
int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20,
|
int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20,
|
||||||
int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20
|
int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Search playlists if enabled
|
||||||
|
Task<List<ExternalPlaylist>> playlistTask = _subsonicSettings.EnableExternalPlaylists
|
||||||
|
? _metadataService.SearchPlaylistsAsync(cleanQuery, ac) // Use same limit as albums
|
||||||
|
: Task.FromResult(new List<ExternalPlaylist>());
|
||||||
|
|
||||||
await Task.WhenAll(subsonicTask, externalTask);
|
await Task.WhenAll(subsonicTask, externalTask, playlistTask);
|
||||||
|
|
||||||
var subsonicResult = await subsonicTask;
|
var subsonicResult = await subsonicTask;
|
||||||
var externalResult = await externalTask;
|
var externalResult = await externalTask;
|
||||||
|
var playlistResult = await playlistTask;
|
||||||
|
|
||||||
return MergeSearchResults(subsonicResult, externalResult, format);
|
return MergeSearchResults(subsonicResult, externalResult, playlistResult, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -339,12 +349,54 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
|
return _responseBuilder.CreateError(format, 10, "Missing id parameter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is an external playlist
|
||||||
|
if (PlaylistIdHelper.IsExternalPlaylist(id))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
|
|
||||||
|
// Get playlist metadata
|
||||||
|
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
return _responseBuilder.CreateError(format, 70, "Playlist not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get playlist tracks
|
||||||
|
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||||
|
|
||||||
|
// Add all tracks to playlist cache so when they're played, we know they belong to this playlist
|
||||||
|
if (_playlistSyncService != null)
|
||||||
|
{
|
||||||
|
foreach (var track in tracks)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(track.ExternalId))
|
||||||
|
{
|
||||||
|
var trackId = $"ext-{provider}-{track.ExternalId}";
|
||||||
|
_playlistSyncService.AddTrackToPlaylistCache(trackId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Added {TrackCount} tracks to playlist cache for {PlaylistId}", tracks.Count, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to album response (playlist as album)
|
||||||
|
return _responseBuilder.CreatePlaylistAsAlbumResponse(format, playlist, tracks);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting playlist {Id}", id);
|
||||||
|
return _responseBuilder.CreateError(format, 70, "Playlist not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(id);
|
var (isExternal, albumProvider, albumExternalId) = _localLibraryService.ParseSongId(id);
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
var album = await _metadataService.GetAlbumAsync(provider!, externalId!);
|
var album = await _metadataService.GetAlbumAsync(albumProvider!, albumExternalId!);
|
||||||
|
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
{
|
||||||
@@ -491,8 +543,39 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a playlist cover art request
|
||||||
|
if (PlaylistIdHelper.IsExternalPlaylist(id))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
|
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
|
||||||
|
if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and return the cover image
|
||||||
|
var imageResponse = await new HttpClient().GetAsync(playlist.CoverUrl);
|
||||||
|
if (!imageResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
||||||
|
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||||
|
return File(imageBytes, contentType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting playlist cover art for {Id}", id);
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(id);
|
var (isExternal, coverProvider, type, coverExternalId) = _localLibraryService.ParseExternalId(id);
|
||||||
|
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
@@ -514,7 +597,7 @@ public class SubsonicController : ControllerBase
|
|||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case "artist":
|
case "artist":
|
||||||
var artist = await _metadataService.GetArtistAsync(provider!, externalId!);
|
var artist = await _metadataService.GetArtistAsync(coverProvider!, coverExternalId!);
|
||||||
if (artist?.ImageUrl != null)
|
if (artist?.ImageUrl != null)
|
||||||
{
|
{
|
||||||
coverUrl = artist.ImageUrl;
|
coverUrl = artist.ImageUrl;
|
||||||
@@ -522,7 +605,7 @@ public class SubsonicController : ControllerBase
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "album":
|
case "album":
|
||||||
var album = await _metadataService.GetAlbumAsync(provider!, externalId!);
|
var album = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!);
|
||||||
if (album?.CoverArtUrl != null)
|
if (album?.CoverArtUrl != null)
|
||||||
{
|
{
|
||||||
coverUrl = album.CoverArtUrl;
|
coverUrl = album.CoverArtUrl;
|
||||||
@@ -532,7 +615,7 @@ public class SubsonicController : ControllerBase
|
|||||||
case "song":
|
case "song":
|
||||||
default:
|
default:
|
||||||
// For songs, try to get from song first, then album
|
// For songs, try to get from song first, then album
|
||||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
var song = await _metadataService.GetSongAsync(coverProvider!, coverExternalId!);
|
||||||
if (song?.CoverArtUrl != null)
|
if (song?.CoverArtUrl != null)
|
||||||
{
|
{
|
||||||
coverUrl = song.CoverArtUrl;
|
coverUrl = song.CoverArtUrl;
|
||||||
@@ -540,7 +623,7 @@ public class SubsonicController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fallback: try album with same ID (legacy behavior)
|
// Fallback: try album with same ID (legacy behavior)
|
||||||
var albumFallback = await _metadataService.GetAlbumAsync(provider!, externalId!);
|
var albumFallback = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!);
|
||||||
if (albumFallback?.CoverArtUrl != null)
|
if (albumFallback?.CoverArtUrl != null)
|
||||||
{
|
{
|
||||||
coverUrl = albumFallback.CoverArtUrl;
|
coverUrl = albumFallback.CoverArtUrl;
|
||||||
@@ -569,6 +652,7 @@ public class SubsonicController : ControllerBase
|
|||||||
private IActionResult MergeSearchResults(
|
private IActionResult MergeSearchResults(
|
||||||
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
|
(byte[]? Body, string? ContentType, bool Success) subsonicResult,
|
||||||
SearchResult externalResult,
|
SearchResult externalResult,
|
||||||
|
List<ExternalPlaylist> playlistResult,
|
||||||
string format)
|
string format)
|
||||||
{
|
{
|
||||||
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
|
var (localSongs, localAlbums, localArtists) = subsonicResult.Success && subsonicResult.Body != null
|
||||||
@@ -580,7 +664,8 @@ public class SubsonicController : ControllerBase
|
|||||||
localSongs,
|
localSongs,
|
||||||
localAlbums,
|
localAlbums,
|
||||||
localArtists,
|
localArtists,
|
||||||
externalResult,
|
externalResult,
|
||||||
|
playlistResult,
|
||||||
isJson);
|
isJson);
|
||||||
|
|
||||||
if (isJson)
|
if (isJson)
|
||||||
@@ -643,7 +728,60 @@ public class SubsonicController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stars (favorites) an item. For playlists, this triggers a full download.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet, HttpPost]
|
||||||
|
[Route("rest/star")]
|
||||||
|
[Route("rest/star.view")]
|
||||||
|
public async Task<IActionResult> Star()
|
||||||
|
{
|
||||||
|
var parameters = await ExtractAllParameters();
|
||||||
|
var format = parameters.GetValueOrDefault("f", "xml");
|
||||||
|
|
||||||
|
// Check if this is a playlist
|
||||||
|
var playlistId = parameters.GetValueOrDefault("id", "");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(playlistId) && PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||||
|
{
|
||||||
|
if (_playlistSyncService == null)
|
||||||
|
{
|
||||||
|
return _responseBuilder.CreateError(format, 0, "Playlist functionality is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Starring external playlist {PlaylistId}, triggering download", playlistId);
|
||||||
|
|
||||||
|
// Trigger playlist download in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _playlistSyncService.DownloadFullPlaylistAsync(playlistId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return success response immediately
|
||||||
|
return _responseBuilder.CreateResponse(format, "starred", new { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-playlist items, relay to real Subsonic server
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _proxyService.RelayAsync("rest/star", parameters);
|
||||||
|
var contentType = result.ContentType ?? $"application/{format}";
|
||||||
|
return File(result.Body, contentType);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generic endpoint to handle all subsonic API calls
|
// Generic endpoint to handle all subsonic API calls
|
||||||
[HttpGet, HttpPost]
|
[HttpGet, HttpPost]
|
||||||
[Route("{**endpoint}")]
|
[Route("{**endpoint}")]
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ public enum ExplicitFilter
|
|||||||
CleanOnly
|
CleanOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage mode for downloaded tracks
|
||||||
|
/// </summary>
|
||||||
|
public enum StorageMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Files are permanently stored in the library and registered in the database
|
||||||
|
/// </summary>
|
||||||
|
Permanent,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Files are stored in a temporary cache and automatically cleaned up
|
||||||
|
/// Not registered in the database, no Navidrome scan triggered
|
||||||
|
/// </summary>
|
||||||
|
Cache
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Music service provider
|
/// Music service provider
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -85,5 +102,38 @@ public class SubsonicSettings
|
|||||||
/// Environment variable: MUSIC_SERVICE
|
/// Environment variable: MUSIC_SERVICE
|
||||||
/// Values: "Deezer", "Qobuz", "SquidWTF"
|
/// Values: "Deezer", "Qobuz", "SquidWTF"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
||||||
public MusicService MusicService { get; set; } = MusicService.SquidWTF;
|
public MusicService MusicService { get; set; } = MusicService.SquidWTF;
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage mode for downloaded files (default: Permanent)
|
||||||
|
/// Environment variable: STORAGE_MODE
|
||||||
|
/// Values: "Permanent" (files saved to library), "Cache" (temporary files, auto-cleanup)
|
||||||
|
/// </summary>
|
||||||
|
public StorageMode StorageMode { get; set; } = StorageMode.Permanent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache duration in hours for Cache storage mode (default: 1)
|
||||||
|
/// Environment variable: CACHE_DURATION_HOURS
|
||||||
|
/// Files older than this duration will be automatically deleted
|
||||||
|
/// Only applies when StorageMode is Cache
|
||||||
|
/// </summary>
|
||||||
|
public int CacheDurationHours { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable external playlist search and streaming (default: true)
|
||||||
|
/// Environment variable: ENABLE_EXTERNAL_PLAYLISTS
|
||||||
|
/// When enabled, users can search for playlists from the configured music provider
|
||||||
|
/// Playlists appear as "albums" in search results with genre "Playlist"
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableExternalPlaylists { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directory name for storing playlist .m3u files (default: "playlists")
|
||||||
|
/// Environment variable: PLAYLISTS_DIRECTORY
|
||||||
|
/// Relative to the music library root directory
|
||||||
|
/// Playlist files will be stored in {MusicDirectory}/{PlaylistsDirectory}/
|
||||||
|
/// </summary>
|
||||||
|
public string PlaylistsDirectory { get; set; } = "playlists";
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
58
octo-fiesta/Models/Subsonic/ExternalPlaylist.cs
Normal file
58
octo-fiesta/Models/Subsonic/ExternalPlaylist.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
namespace octo_fiesta.Models.Subsonic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a playlist from an external music provider (Deezer, Qobuz).
|
||||||
|
/// </summary>
|
||||||
|
public class ExternalPlaylist
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier in the format "pl-{provider}-{externalId}"
|
||||||
|
/// Example: "pl-deezer-123456" or "pl-qobuz-789"
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist name
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist description
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name of the playlist creator/curator
|
||||||
|
/// </summary>
|
||||||
|
public string? CuratorName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider name ("deezer" or "qobuz")
|
||||||
|
/// </summary>
|
||||||
|
public string Provider { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// External ID from the provider (without "pl-" prefix)
|
||||||
|
/// </summary>
|
||||||
|
public string ExternalId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of tracks in the playlist
|
||||||
|
/// </summary>
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total duration in seconds
|
||||||
|
/// </summary>
|
||||||
|
public int Duration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cover art URL from the provider
|
||||||
|
/// </summary>
|
||||||
|
public string? CoverUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist creation date
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using octo_fiesta.Services.SquidWTF;
|
|||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
using octo_fiesta.Services.Validation;
|
using octo_fiesta.Services.Validation;
|
||||||
using octo_fiesta.Services.Subsonic;
|
using octo_fiesta.Services.Subsonic;
|
||||||
|
using octo_fiesta.Services.Common;
|
||||||
using octo_fiesta.Middleware;
|
using octo_fiesta.Middleware;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -32,6 +33,7 @@ builder.Services.Configure<QobuzSettings>(
|
|||||||
|
|
||||||
// Get the configured music service
|
// Get the configured music service
|
||||||
var musicService = builder.Configuration.GetValue<MusicService>("Subsonic:MusicService");
|
var musicService = builder.Configuration.GetValue<MusicService>("Subsonic:MusicService");
|
||||||
|
var enableExternalPlaylists = builder.Configuration.GetValue<bool>("Subsonic:EnableExternalPlaylists", true);
|
||||||
|
|
||||||
// Business services
|
// Business services
|
||||||
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
|
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
|
||||||
@@ -44,16 +46,35 @@ builder.Services.AddSingleton<SubsonicModelMapper>();
|
|||||||
builder.Services.AddScoped<SubsonicProxyService>();
|
builder.Services.AddScoped<SubsonicProxyService>();
|
||||||
|
|
||||||
// Register music service based on configuration
|
// Register music service based on configuration
|
||||||
|
// IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI
|
||||||
|
// will use the last registered implementation when injecting IMusicMetadataService/IDownloadService
|
||||||
if (musicService == MusicService.Qobuz)
|
if (musicService == MusicService.Qobuz)
|
||||||
{
|
{
|
||||||
// Qobuz services
|
// If playlists enabled, register Deezer FIRST (secondary provider)
|
||||||
|
if (enableExternalPlaylists)
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||||
|
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||||
|
builder.Services.AddSingleton<PlaylistSyncService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qobuz services (primary) - registered LAST to be injected by default
|
||||||
builder.Services.AddSingleton<QobuzBundleService>();
|
builder.Services.AddSingleton<QobuzBundleService>();
|
||||||
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||||
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||||
}
|
}
|
||||||
else if (musicService == MusicService.Deezer)
|
else if (musicService == MusicService.Deezer)
|
||||||
{
|
{
|
||||||
// Deezer services (default)
|
// If playlists enabled, register Qobuz FIRST (secondary provider)
|
||||||
|
if (enableExternalPlaylists)
|
||||||
|
{
|
||||||
|
builder.Services.AddSingleton<QobuzBundleService>();
|
||||||
|
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||||
|
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||||
|
builder.Services.AddSingleton<PlaylistSyncService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer services (primary, default) - registered LAST to be injected by default
|
||||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||||
}
|
}
|
||||||
@@ -73,6 +94,9 @@ builder.Services.AddSingleton<IStartupValidator, SquidWTFStartupValidator>();
|
|||||||
// Register orchestrator as hosted service
|
// Register orchestrator as hosted service
|
||||||
builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||||
|
|
||||||
|
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||||
|
builder.Services.AddHostedService<CacheCleanupService>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using octo_fiesta.Models.Download;
|
|||||||
using octo_fiesta.Models.Search;
|
using octo_fiesta.Models.Search;
|
||||||
using octo_fiesta.Models.Subsonic;
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
|
using octo_fiesta.Services.Subsonic;
|
||||||
using TagLib;
|
using TagLib;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -21,12 +22,30 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
protected readonly IMusicMetadataService MetadataService;
|
protected readonly IMusicMetadataService MetadataService;
|
||||||
protected readonly SubsonicSettings SubsonicSettings;
|
protected readonly SubsonicSettings SubsonicSettings;
|
||||||
protected readonly ILogger Logger;
|
protected readonly ILogger Logger;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
protected readonly string DownloadPath;
|
protected readonly string DownloadPath;
|
||||||
|
protected readonly string CachePath;
|
||||||
|
|
||||||
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
|
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||||
|
/// </summary>
|
||||||
|
private PlaylistSyncService? _playlistSyncService;
|
||||||
|
protected PlaylistSyncService? PlaylistSyncService
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_playlistSyncService == null)
|
||||||
|
{
|
||||||
|
_playlistSyncService = _serviceProvider.GetService<PlaylistSyncService>();
|
||||||
|
}
|
||||||
|
return _playlistSyncService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provider name (e.g., "deezer", "qobuz")
|
/// Provider name (e.g., "deezer", "qobuz")
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -37,20 +56,28 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
SubsonicSettings subsonicSettings,
|
SubsonicSettings subsonicSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger logger)
|
ILogger logger)
|
||||||
{
|
{
|
||||||
Configuration = configuration;
|
Configuration = configuration;
|
||||||
LocalLibraryService = localLibraryService;
|
LocalLibraryService = localLibraryService;
|
||||||
MetadataService = metadataService;
|
MetadataService = metadataService;
|
||||||
SubsonicSettings = subsonicSettings;
|
SubsonicSettings = subsonicSettings;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
Logger = logger;
|
Logger = logger;
|
||||||
|
|
||||||
DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||||
|
CachePath = PathHelper.GetCachePath();
|
||||||
|
|
||||||
if (!Directory.Exists(DownloadPath))
|
if (!Directory.Exists(DownloadPath))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(DownloadPath);
|
Directory.CreateDirectory(DownloadPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(CachePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(CachePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region IDownloadService Implementation
|
#region IDownloadService Implementation
|
||||||
@@ -62,7 +89,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken);
|
var localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||||
return IOFile.OpenRead(localPath);
|
return IOFile.OpenRead(localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +99,30 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
if (externalProvider != ProviderName)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local library
|
||||||
|
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
|
if (localPath != null && IOFile.Exists(localPath))
|
||||||
|
{
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache directory
|
||||||
|
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
||||||
|
if (cachedPath != null && IOFile.Exists(cachedPath))
|
||||||
|
{
|
||||||
|
return cachedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public abstract Task<bool> IsAvailableAsync();
|
public abstract Task<bool> IsAvailableAsync();
|
||||||
|
|
||||||
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
|
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
|
||||||
@@ -130,37 +181,86 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songId = $"ext-{externalProvider}-{externalId}";
|
var songId = $"ext-{externalProvider}-{externalId}";
|
||||||
|
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache;
|
||||||
|
|
||||||
// Check if already downloaded
|
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
|
||||||
if (existingPath != null && IOFile.Exists(existingPath))
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
|
||||||
return existingPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if download in progress
|
|
||||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Download already in progress for {SongId}", songId);
|
|
||||||
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
|
||||||
{
|
|
||||||
await Task.Delay(500, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
|
||||||
{
|
|
||||||
return activeDownload.LocalPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await DownloadLock.WaitAsync(cancellationToken);
|
await DownloadLock.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check if already downloaded (skip for cache mode as we want to check cache folder)
|
||||||
|
if (!isCache)
|
||||||
|
{
|
||||||
|
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
|
if (existingPath != null && IOFile.Exists(existingPath))
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||||
|
return existingPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For cache mode, check if file exists in cache directory
|
||||||
|
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
||||||
|
if (cachedPath != null && IOFile.Exists(cachedPath))
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Song found in cache: {Path}", cachedPath);
|
||||||
|
// Update file access time for cache cleanup logic
|
||||||
|
IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow);
|
||||||
|
return cachedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if download in progress
|
||||||
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
|
||||||
|
// Release lock while waiting
|
||||||
|
DownloadLock.Release();
|
||||||
|
|
||||||
|
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
|
{
|
||||||
|
await Task.Delay(500, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||||
|
{
|
||||||
|
return activeDownload.LocalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
||||||
|
}
|
||||||
|
|
||||||
// Get metadata
|
// Get metadata
|
||||||
var song = await MetadataService.GetSongAsync(externalProvider, externalId);
|
// In Album mode, fetch the full album first to ensure AlbumArtist is correctly set
|
||||||
|
Song? song = null;
|
||||||
|
|
||||||
|
if (SubsonicSettings.DownloadMode == DownloadMode.Album)
|
||||||
|
{
|
||||||
|
// First try to get the song to extract album ID
|
||||||
|
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
|
||||||
|
if (tempSong != null && !string.IsNullOrEmpty(tempSong.AlbumId))
|
||||||
|
{
|
||||||
|
var albumExternalId = ExtractExternalIdFromAlbumId(tempSong.AlbumId);
|
||||||
|
if (!string.IsNullOrEmpty(albumExternalId))
|
||||||
|
{
|
||||||
|
// Get full album with correct AlbumArtist
|
||||||
|
var album = await MetadataService.GetAlbumAsync(externalProvider, albumExternalId);
|
||||||
|
if (album != null)
|
||||||
|
{
|
||||||
|
// Find the track in the album
|
||||||
|
song = album.Songs.FirstOrDefault(s => s.ExternalId == externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to individual song fetch if not in Album mode or album fetch failed
|
||||||
|
if (song == null)
|
||||||
|
{
|
||||||
|
song = await MetadataService.GetSongAsync(externalProvider, externalId);
|
||||||
|
}
|
||||||
|
|
||||||
if (song == null)
|
if (song == null)
|
||||||
{
|
{
|
||||||
throw new Exception("Song not found");
|
throw new Exception("Song not found");
|
||||||
@@ -176,15 +276,35 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
};
|
};
|
||||||
ActiveDownloads[songId] = downloadInfo;
|
ActiveDownloads[songId] = downloadInfo;
|
||||||
|
|
||||||
try
|
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
|
||||||
|
|
||||||
|
downloadInfo.Status = DownloadStatus.Completed;
|
||||||
|
downloadInfo.LocalPath = localPath;
|
||||||
|
downloadInfo.CompletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
song.LocalPath = localPath;
|
||||||
|
|
||||||
|
// Check if this track belongs to a playlist and update M3U
|
||||||
|
if (PlaylistSyncService != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var playlistId = PlaylistSyncService.GetPlaylistIdForTrack(songId);
|
||||||
|
if (playlistId != null)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId);
|
||||||
|
await PlaylistSyncService.AddTrackToM3UAsync(playlistId, song, localPath, isFullPlaylistDownload: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only register and scan if NOT in cache mode
|
||||||
|
if (!isCache)
|
||||||
{
|
{
|
||||||
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
|
|
||||||
|
|
||||||
downloadInfo.Status = DownloadStatus.Completed;
|
|
||||||
downloadInfo.LocalPath = localPath;
|
|
||||||
downloadInfo.CompletedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
song.LocalPath = localPath;
|
|
||||||
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||||
|
|
||||||
// Trigger a Subsonic library rescan (with debounce)
|
// Trigger a Subsonic library rescan (with debounce)
|
||||||
@@ -210,17 +330,24 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId);
|
DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.LogInformation("Download completed: {Path}", localPath);
|
|
||||||
return localPath;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Cache mode: skipping library registration and scan");
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogInformation("Download completed: {Path}", localPath);
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (ActiveDownloads.TryGetValue(songId, out var downloadInfo))
|
||||||
{
|
{
|
||||||
downloadInfo.Status = DownloadStatus.Failed;
|
downloadInfo.Status = DownloadStatus.Failed;
|
||||||
downloadInfo.ErrorMessage = ex.Message;
|
downloadInfo.ErrorMessage = ex.Message;
|
||||||
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
|
||||||
throw;
|
|
||||||
}
|
}
|
||||||
|
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -258,6 +385,23 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if download is already in progress or recently completed
|
||||||
|
var songId = $"ext-{ProviderName}-{track.ExternalId}";
|
||||||
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload))
|
||||||
|
{
|
||||||
|
if (activeDownload.Status == DownloadStatus.InProgress)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Track {TrackId} download already in progress, skipping", track.ExternalId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDownload.Status == DownloadStatus.Completed)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Track {TrackId} already downloaded in this session, skipping", track.ExternalId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
|
Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
|
||||||
await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None);
|
await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -401,5 +545,31 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the cached file path for a given provider and external ID
|
||||||
|
/// Returns null if no cached file exists
|
||||||
|
/// </summary>
|
||||||
|
protected string? GetCachedFilePath(string provider, string externalId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Search for cached files matching the pattern: {provider}_{externalId}.*
|
||||||
|
var pattern = $"{provider}_{externalId}.*";
|
||||||
|
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
if (files.Length > 0)
|
||||||
|
{
|
||||||
|
return files[0]; // Return first match
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
163
octo-fiesta/Services/Common/CacheCleanupService.cs
Normal file
163
octo-fiesta/Services/Common/CacheCleanupService.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using octo_fiesta.Models.Settings;
|
||||||
|
|
||||||
|
namespace octo_fiesta.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that periodically cleans up old cached files
|
||||||
|
/// Only runs when StorageMode is set to Cache
|
||||||
|
/// </summary>
|
||||||
|
public class CacheCleanupService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
|
private readonly ILogger<CacheCleanupService> _logger;
|
||||||
|
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
public CacheCleanupService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
|
ILogger<CacheCleanupService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Only run if storage mode is Cache
|
||||||
|
if (_subsonicSettings.StorageMode != StorageMode.Cache)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("CacheCleanupService disabled: StorageMode is not Cache");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("CacheCleanupService started with cleanup interval of {Interval} and retention of {Hours} hours",
|
||||||
|
_cleanupInterval, _subsonicSettings.CacheDurationHours);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CleanupOldCachedFilesAsync(stoppingToken);
|
||||||
|
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Service is stopping, exit gracefully
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during cache cleanup");
|
||||||
|
// Continue running even if cleanup fails
|
||||||
|
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("CacheCleanupService stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cachePath = PathHelper.GetCachePath();
|
||||||
|
|
||||||
|
if (!Directory.Exists(cachePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cache directory does not exist: {Path}", cachePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutoffTime = DateTime.UtcNow.AddHours(-_subsonicSettings.CacheDurationHours);
|
||||||
|
var deletedCount = 0;
|
||||||
|
var totalSize = 0L;
|
||||||
|
|
||||||
|
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all files in cache directory and subdirectories
|
||||||
|
var files = Directory.GetFiles(cachePath, "*.*", SearchOption.AllDirectories);
|
||||||
|
|
||||||
|
foreach (var filePath in files)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
|
||||||
|
// Use last access time to determine if file should be deleted
|
||||||
|
// This gets updated when a cached file is streamed
|
||||||
|
if (fileInfo.LastAccessTimeUtc < cutoffTime)
|
||||||
|
{
|
||||||
|
var size = fileInfo.Length;
|
||||||
|
File.Delete(filePath);
|
||||||
|
deletedCount++;
|
||||||
|
totalSize += size;
|
||||||
|
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
|
||||||
|
filePath, fileInfo.LastAccessTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty directories
|
||||||
|
await CleanupEmptyDirectoriesAsync(cachePath, cancellationToken);
|
||||||
|
|
||||||
|
if (deletedCount > 0)
|
||||||
|
{
|
||||||
|
var sizeMB = totalSize / (1024.0 * 1024.0);
|
||||||
|
_logger.LogInformation("Cache cleanup completed: deleted {Count} files, freed {Size:F2} MB",
|
||||||
|
deletedCount, sizeMB);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cache cleanup completed: no files to delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during cache cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupEmptyDirectoriesAsync(string rootPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directories = Directory.GetDirectories(rootPath, "*", SearchOption.AllDirectories)
|
||||||
|
.OrderByDescending(d => d.Length); // Process deepest directories first
|
||||||
|
|
||||||
|
foreach (var directory in directories)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
|
{
|
||||||
|
Directory.Delete(directory);
|
||||||
|
_logger.LogDebug("Deleted empty directory: {Path}", directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error cleaning up empty directories");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,17 @@ namespace octo_fiesta.Services.Common;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PathHelper
|
public static class PathHelper
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the cache directory path for temporary file storage.
|
||||||
|
/// Uses system temp directory combined with octo-fiesta-cache subfolder.
|
||||||
|
/// Respects TMPDIR environment variable on Linux/macOS.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Full path to the cache directory.</returns>
|
||||||
|
public static string GetCachePath()
|
||||||
|
{
|
||||||
|
return Path.Combine(Path.GetTempPath(), "octo-fiesta-cache");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the output path for a downloaded track following the Artist/Album/Track structure.
|
/// Builds the output path for a downloaded track following the Artist/Album/Track structure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
76
octo-fiesta/Services/Common/PlaylistIdHelper.cs
Normal file
76
octo-fiesta/Services/Common/PlaylistIdHelper.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
namespace octo_fiesta.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for handling external playlist IDs.
|
||||||
|
/// Playlist IDs use the format: "pl-{provider}-{externalId}"
|
||||||
|
/// Example: "pl-deezer-123456", "pl-qobuz-789"
|
||||||
|
/// </summary>
|
||||||
|
public static class PlaylistIdHelper
|
||||||
|
{
|
||||||
|
private const string PlaylistPrefix = "pl-";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if an ID represents an external playlist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The ID to check</param>
|
||||||
|
/// <returns>True if the ID starts with "pl-", false otherwise</returns>
|
||||||
|
public static bool IsExternalPlaylist(string? id)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a playlist ID to extract provider and external ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The playlist ID in format "pl-{provider}-{externalId}"</param>
|
||||||
|
/// <returns>A tuple containing (provider, externalId)</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown if the ID format is invalid</exception>
|
||||||
|
public static (string provider, string externalId) ParsePlaylistId(string id)
|
||||||
|
{
|
||||||
|
if (!IsExternalPlaylist(id))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove "pl-" prefix
|
||||||
|
var withoutPrefix = id.Substring(PlaylistPrefix.Length);
|
||||||
|
|
||||||
|
// Split by first dash to get provider and externalId
|
||||||
|
var dashIndex = withoutPrefix.IndexOf('-');
|
||||||
|
if (dashIndex == -1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = withoutPrefix.Substring(0, dashIndex);
|
||||||
|
var externalId = withoutPrefix.Substring(dashIndex + 1);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Invalid playlist ID format. Provider or external ID is empty in '{id}'", nameof(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (provider, externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a playlist ID from provider and external ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The provider name (e.g., "deezer", "qobuz")</param>
|
||||||
|
/// <param name="externalId">The external ID from the provider</param>
|
||||||
|
/// <returns>A playlist ID in format "pl-{provider}-{externalId}"</returns>
|
||||||
|
public static string CreatePlaylistId(string provider, string externalId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(provider))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Provider cannot be null or empty", nameof(provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("External ID cannot be null or empty", nameof(externalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{PlaylistPrefix}{provider.ToLowerInvariant()}-{externalId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ using octo_fiesta.Models.Search;
|
|||||||
using octo_fiesta.Models.Subsonic;
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
using octo_fiesta.Services.Common;
|
using octo_fiesta.Services.Common;
|
||||||
|
using octo_fiesta.Services.Subsonic;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -50,8 +51,9 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IOptions<DeezerSettings> deezerSettings,
|
IOptions<DeezerSettings> deezerSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger<DeezerDownloadService> logger)
|
ILogger<DeezerDownloadService> logger)
|
||||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
|
||||||
@@ -109,7 +111,8 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||||
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
|||||||
@@ -229,8 +229,14 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
int trackIndex = 1;
|
int trackIndex = 1;
|
||||||
foreach (var track in tracksData.EnumerateArray())
|
foreach (var track in tracksData.EnumerateArray())
|
||||||
{
|
{
|
||||||
// Pass the index as fallback for track_position (Deezer doesn't include it in album tracks)
|
// Pass the album artist to ensure proper folder organization
|
||||||
var song = ParseDeezerTrack(track, trackIndex);
|
var song = ParseDeezerTrack(track, trackIndex, album.Artist);
|
||||||
|
|
||||||
|
// Ensure album metadata is set (tracks in album response may not have full album object)
|
||||||
|
song.Album = album.Title;
|
||||||
|
song.AlbumId = album.Id;
|
||||||
|
song.AlbumArtist = album.Artist;
|
||||||
|
|
||||||
if (ShouldIncludeSong(song))
|
if (ShouldIncludeSong(song))
|
||||||
{
|
{
|
||||||
album.Songs.Add(song);
|
album.Songs.Add(song);
|
||||||
@@ -283,7 +289,7 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
return albums;
|
return albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null)
|
private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null)
|
||||||
{
|
{
|
||||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||||
|
|
||||||
@@ -321,6 +327,7 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
albumForCover.TryGetProperty("cover_medium", out var cover)
|
albumForCover.TryGetProperty("cover_medium", out var cover)
|
||||||
? cover.GetString()
|
? cover.GetString()
|
||||||
: null,
|
: null,
|
||||||
|
AlbumArtist = albumArtist,
|
||||||
IsLocal = false,
|
IsLocal = false,
|
||||||
ExternalProvider = "deezer",
|
ExternalProvider = "deezer",
|
||||||
ExternalId = externalId,
|
ExternalId = externalId,
|
||||||
@@ -510,6 +517,163 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var playlists = new List<ExternalPlaylist>();
|
||||||
|
if (result.RootElement.TryGetProperty("data", out var data))
|
||||||
|
{
|
||||||
|
foreach (var playlist in data.EnumerateArray())
|
||||||
|
{
|
||||||
|
playlists.Add(ParseDeezerPlaylist(playlist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<ExternalPlaylist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
if (externalProvider != "deezer") return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{BaseUrl}/playlist/{externalId}";
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
|
return ParseDeezerPlaylist(playlistElement);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
if (externalProvider != "deezer") return new List<Song>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{BaseUrl}/playlist/{externalId}";
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
||||||
|
|
||||||
|
var songs = new List<Song>();
|
||||||
|
|
||||||
|
// Get playlist name for album field
|
||||||
|
var playlistName = playlistElement.TryGetProperty("title", out var titleEl)
|
||||||
|
? titleEl.GetString() ?? "Unknown Playlist"
|
||||||
|
: "Unknown Playlist";
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("tracks", out var tracks) &&
|
||||||
|
tracks.TryGetProperty("data", out var tracksData))
|
||||||
|
{
|
||||||
|
int trackIndex = 1;
|
||||||
|
foreach (var track in tracksData.EnumerateArray())
|
||||||
|
{
|
||||||
|
// For playlists, use the track's own artist (not a single album artist)
|
||||||
|
var song = ParseDeezerTrack(track, trackIndex);
|
||||||
|
|
||||||
|
// Override album name to be the playlist name
|
||||||
|
song.Album = playlistName;
|
||||||
|
|
||||||
|
if (ShouldIncludeSong(song))
|
||||||
|
{
|
||||||
|
songs.Add(song);
|
||||||
|
}
|
||||||
|
trackIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return songs;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<Song>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist)
|
||||||
|
{
|
||||||
|
var externalId = playlist.GetProperty("id").GetInt64().ToString();
|
||||||
|
|
||||||
|
// Get curator/creator name
|
||||||
|
string? curatorName = null;
|
||||||
|
if (playlist.TryGetProperty("user", out var user) &&
|
||||||
|
user.TryGetProperty("name", out var userName))
|
||||||
|
{
|
||||||
|
curatorName = userName.GetString();
|
||||||
|
}
|
||||||
|
else if (playlist.TryGetProperty("creator", out var creator) &&
|
||||||
|
creator.TryGetProperty("name", out var creatorName))
|
||||||
|
{
|
||||||
|
curatorName = creatorName.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get creation date
|
||||||
|
DateTime? createdDate = null;
|
||||||
|
if (playlist.TryGetProperty("creation_date", out var creationDateEl))
|
||||||
|
{
|
||||||
|
var dateStr = creationDateEl.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date))
|
||||||
|
{
|
||||||
|
createdDate = date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExternalPlaylist
|
||||||
|
{
|
||||||
|
Id = Common.PlaylistIdHelper.CreatePlaylistId("deezer", externalId),
|
||||||
|
Name = playlist.GetProperty("title").GetString() ?? "",
|
||||||
|
Description = playlist.TryGetProperty("description", out var desc)
|
||||||
|
? desc.GetString()
|
||||||
|
: null,
|
||||||
|
CuratorName = curatorName,
|
||||||
|
Provider = "deezer",
|
||||||
|
ExternalId = externalId,
|
||||||
|
TrackCount = playlist.TryGetProperty("nb_tracks", out var nbTracks)
|
||||||
|
? nbTracks.GetInt32()
|
||||||
|
: 0,
|
||||||
|
Duration = playlist.TryGetProperty("duration", out var duration)
|
||||||
|
? duration.GetInt32()
|
||||||
|
: 0,
|
||||||
|
CoverUrl = playlist.TryGetProperty("picture_medium", out var picture)
|
||||||
|
? picture.GetString()
|
||||||
|
: (playlist.TryGetProperty("picture_big", out var pictureBig)
|
||||||
|
? pictureBig.GetString()
|
||||||
|
: null),
|
||||||
|
CreatedDate = createdDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether a song should be included based on the explicit content filter setting
|
/// Determines whether a song should be included based on the explicit content filter setting
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ public interface IDownloadService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
DownloadInfo? GetDownloadStatus(string songId);
|
DownloadInfo? GetDownloadStatus(string songId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the local path for a song if it has been downloaded already
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="externalProvider">The provider (deezer, qobuz, etc.)</param>
|
||||||
|
/// <param name="externalId">The ID on the external provider</param>
|
||||||
|
/// <returns>The local file path if exists, null otherwise</returns>
|
||||||
|
Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the service is properly configured and functional
|
/// Checks if the service is properly configured and functional
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -54,4 +54,28 @@ public interface IMusicMetadataService
|
|||||||
/// Gets an artist's albums
|
/// Gets an artist's albums
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
|
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for playlists on external providers
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Search term</param>
|
||||||
|
/// <param name="limit">Maximum number of results</param>
|
||||||
|
/// <returns>List of found playlists</returns>
|
||||||
|
Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets details of an external playlist (metadata only, not tracks)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
|
||||||
|
/// <param name="externalId">Playlist ID from the provider</param>
|
||||||
|
/// <returns>Playlist details or null if not found</returns>
|
||||||
|
Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all tracks from an external playlist
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
|
||||||
|
/// <param name="externalId">Playlist ID from the provider</param>
|
||||||
|
/// <returns>List of songs in the playlist</returns>
|
||||||
|
Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class QobuzBundleService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Qobuz App ID, extracting it from the bundle if not cached
|
/// Gets the Qobuz App ID, extracting it from the bundle if not cached
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> GetAppIdAsync()
|
public virtual async Task<string> GetAppIdAsync()
|
||||||
{
|
{
|
||||||
await EnsureInitializedAsync();
|
await EnsureInitializedAsync();
|
||||||
return _cachedAppId!;
|
return _cachedAppId!;
|
||||||
@@ -49,7 +49,7 @@ public class QobuzBundleService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Qobuz secrets list, extracting them from the bundle if not cached
|
/// Gets the Qobuz secrets list, extracting them from the bundle if not cached
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> GetSecretsAsync()
|
public virtual async Task<List<string>> GetSecretsAsync()
|
||||||
{
|
{
|
||||||
await EnsureInitializedAsync();
|
await EnsureInitializedAsync();
|
||||||
return _cachedSecrets!;
|
return _cachedSecrets!;
|
||||||
@@ -58,7 +58,7 @@ public class QobuzBundleService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a specific secret by index (used for signing requests)
|
/// Gets a specific secret by index (used for signing requests)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> GetSecretAsync(int index = 0)
|
public virtual async Task<string> GetSecretAsync(int index = 0)
|
||||||
{
|
{
|
||||||
var secrets = await GetSecretsAsync();
|
var secrets = await GetSecretsAsync();
|
||||||
if (index < 0 || index >= secrets.Count)
|
if (index < 0 || index >= secrets.Count)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using octo_fiesta.Models.Search;
|
|||||||
using octo_fiesta.Models.Subsonic;
|
using octo_fiesta.Models.Subsonic;
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
using octo_fiesta.Services.Common;
|
using octo_fiesta.Services.Common;
|
||||||
|
using octo_fiesta.Services.Subsonic;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -43,8 +44,9 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
QobuzBundleService bundleService,
|
QobuzBundleService bundleService,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger<QobuzDownloadService> logger)
|
ILogger<QobuzDownloadService> logger)
|
||||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_bundleService = bundleService;
|
_bundleService = bundleService;
|
||||||
@@ -108,7 +110,8 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||||
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
EnsureDirectoryExists(albumFolder);
|
EnsureDirectoryExists(albumFolder);
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
foreach (var track in tracksData.EnumerateArray())
|
foreach (var track in tracksData.EnumerateArray())
|
||||||
{
|
{
|
||||||
var song = ParseQobuzTrack(track);
|
var song = ParseQobuzTrack(track);
|
||||||
|
|
||||||
|
// Ensure album metadata is set (tracks in album response may not have full album object)
|
||||||
|
song.Album = album.Title;
|
||||||
|
song.AlbumId = album.Id;
|
||||||
|
song.AlbumArtist = album.Artist;
|
||||||
|
|
||||||
if (ShouldIncludeSong(song))
|
if (ShouldIncludeSong(song))
|
||||||
{
|
{
|
||||||
album.Songs.Add(song);
|
album.Songs.Add(song);
|
||||||
@@ -305,6 +311,180 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appId = await _bundleService.GetAppIdAsync();
|
||||||
|
var url = $"{BaseUrl}playlist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
||||||
|
|
||||||
|
var response = await GetWithAuthAsync(url);
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var playlists = new List<ExternalPlaylist>();
|
||||||
|
if (result.RootElement.TryGetProperty("playlists", out var playlistsData) &&
|
||||||
|
playlistsData.TryGetProperty("items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var playlist in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
playlists.Add(ParseQobuzPlaylist(playlist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to search playlists for query: {Query}", query);
|
||||||
|
return new List<ExternalPlaylist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
if (externalProvider != "qobuz") return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appId = await _bundleService.GetAppIdAsync();
|
||||||
|
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}";
|
||||||
|
|
||||||
|
var response = await GetWithAuthAsync(url);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
|
return ParseQobuzPlaylist(playlistElement);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get playlist {ExternalId}", externalId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
if (externalProvider != "qobuz") return new List<Song>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appId = await _bundleService.GetAppIdAsync();
|
||||||
|
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}&extra=tracks";
|
||||||
|
|
||||||
|
var response = await GetWithAuthAsync(url);
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
||||||
|
|
||||||
|
var songs = new List<Song>();
|
||||||
|
|
||||||
|
// Get playlist name for album field
|
||||||
|
var playlistName = playlistElement.TryGetProperty("name", out var nameEl)
|
||||||
|
? nameEl.GetString() ?? "Unknown Playlist"
|
||||||
|
: "Unknown Playlist";
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("tracks", out var tracks) &&
|
||||||
|
tracks.TryGetProperty("items", out var tracksData))
|
||||||
|
{
|
||||||
|
int trackIndex = 1;
|
||||||
|
foreach (var track in tracksData.EnumerateArray())
|
||||||
|
{
|
||||||
|
// For playlists, use the track's own artist (not a single album artist)
|
||||||
|
var song = ParseQobuzTrack(track);
|
||||||
|
|
||||||
|
// Override album name to be the playlist name
|
||||||
|
song.Album = playlistName;
|
||||||
|
song.Track = trackIndex;
|
||||||
|
|
||||||
|
if (ShouldIncludeSong(song))
|
||||||
|
{
|
||||||
|
songs.Add(song);
|
||||||
|
}
|
||||||
|
trackIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return songs;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get playlist tracks for {ExternalId}", externalId);
|
||||||
|
return new List<Song>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExternalPlaylist ParseQobuzPlaylist(JsonElement playlist)
|
||||||
|
{
|
||||||
|
var externalId = GetIdAsString(playlist.GetProperty("id"));
|
||||||
|
|
||||||
|
// Get curator/creator name
|
||||||
|
string? curatorName = null;
|
||||||
|
if (playlist.TryGetProperty("owner", out var owner) &&
|
||||||
|
owner.TryGetProperty("name", out var ownerName))
|
||||||
|
{
|
||||||
|
curatorName = ownerName.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get creation date
|
||||||
|
DateTime? createdDate = null;
|
||||||
|
if (playlist.TryGetProperty("created_at", out var createdAtEl))
|
||||||
|
{
|
||||||
|
var timestamp = createdAtEl.GetInt64();
|
||||||
|
createdDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cover URL from images
|
||||||
|
string? coverUrl = null;
|
||||||
|
if (playlist.TryGetProperty("images300", out var images300))
|
||||||
|
{
|
||||||
|
var imagesArray = images300.EnumerateArray().ToList();
|
||||||
|
if (imagesArray.Count > 0)
|
||||||
|
{
|
||||||
|
coverUrl = imagesArray[0].GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (playlist.TryGetProperty("image_rectangle", out var imageRect))
|
||||||
|
{
|
||||||
|
var imagesArray = imageRect.EnumerateArray().ToList();
|
||||||
|
if (imagesArray.Count > 0)
|
||||||
|
{
|
||||||
|
coverUrl = imagesArray[0].GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExternalPlaylist
|
||||||
|
{
|
||||||
|
Id = Common.PlaylistIdHelper.CreatePlaylistId("qobuz", externalId),
|
||||||
|
Name = playlist.TryGetProperty("name", out var name)
|
||||||
|
? name.GetString() ?? ""
|
||||||
|
: "",
|
||||||
|
Description = playlist.TryGetProperty("description", out var desc)
|
||||||
|
? desc.GetString()
|
||||||
|
: null,
|
||||||
|
CuratorName = curatorName,
|
||||||
|
Provider = "qobuz",
|
||||||
|
ExternalId = externalId,
|
||||||
|
TrackCount = playlist.TryGetProperty("tracks_count", out var tracksCount)
|
||||||
|
? tracksCount.GetInt32()
|
||||||
|
: 0,
|
||||||
|
Duration = playlist.TryGetProperty("duration", out var duration)
|
||||||
|
? duration.GetInt32()
|
||||||
|
: 0,
|
||||||
|
CoverUrl = coverUrl,
|
||||||
|
CreatedDate = createdDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Safely gets an ID value as a string, handling both number and string types from JSON
|
/// Safely gets an ID value as a string, handling both number and string types from JSON
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
411
octo-fiesta/Services/Subsonic/PlaylistSyncService.cs
Normal file
411
octo-fiesta/Services/Subsonic/PlaylistSyncService.cs
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using octo_fiesta.Models.Domain;
|
||||||
|
using octo_fiesta.Models.Settings;
|
||||||
|
using octo_fiesta.Models.Subsonic;
|
||||||
|
using octo_fiesta.Services.Common;
|
||||||
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
|
namespace octo_fiesta.Services.Subsonic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service responsible for downloading playlist tracks and creating M3U files
|
||||||
|
/// </summary>
|
||||||
|
public class PlaylistSyncService
|
||||||
|
{
|
||||||
|
private readonly IMusicMetadataService _deezerMetadataService;
|
||||||
|
private readonly IMusicMetadataService _qobuzMetadataService;
|
||||||
|
private readonly IEnumerable<IDownloadService> _downloadServices;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
|
private readonly ILogger<PlaylistSyncService> _logger;
|
||||||
|
|
||||||
|
// In-memory cache to track which playlist a track belongs to
|
||||||
|
// Key: trackId (format: ext-{provider}-{externalId}), Value: playlistId
|
||||||
|
// TTL: 5 minutes (tracks expire automatically)
|
||||||
|
private readonly ConcurrentDictionary<string, (string PlaylistId, DateTime ExpiresAt)> _trackPlaylistCache = new();
|
||||||
|
private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private readonly string _musicDirectory;
|
||||||
|
private readonly string _playlistDirectory;
|
||||||
|
|
||||||
|
// Cancellation token for background cleanup task
|
||||||
|
private readonly CancellationTokenSource _cleanupCancellationTokenSource = new();
|
||||||
|
private readonly Task _cleanupTask;
|
||||||
|
|
||||||
|
public PlaylistSyncService(
|
||||||
|
IEnumerable<IMusicMetadataService> metadataServices,
|
||||||
|
IEnumerable<IDownloadService> downloadServices,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
|
ILogger<PlaylistSyncService> logger)
|
||||||
|
{
|
||||||
|
// Get Deezer and Qobuz metadata services
|
||||||
|
_deezerMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Deezer"))
|
||||||
|
?? throw new InvalidOperationException("Deezer metadata service not found");
|
||||||
|
_qobuzMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Qobuz"))
|
||||||
|
?? throw new InvalidOperationException("Qobuz metadata service not found");
|
||||||
|
|
||||||
|
_downloadServices = downloadServices;
|
||||||
|
_configuration = configuration;
|
||||||
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
_musicDirectory = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||||
|
_playlistDirectory = Path.Combine(_musicDirectory, _subsonicSettings.PlaylistsDirectory ?? "playlists");
|
||||||
|
|
||||||
|
// Ensure playlists directory exists
|
||||||
|
if (!Directory.Exists(_playlistDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_playlistDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background cleanup task for expired cache entries
|
||||||
|
_cleanupTask = Task.Run(() => CleanupExpiredCacheEntriesAsync(_cleanupCancellationTokenSource.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the metadata service for the specified provider
|
||||||
|
/// </summary>
|
||||||
|
private IMusicMetadataService? GetMetadataServiceForProvider(string provider)
|
||||||
|
{
|
||||||
|
return provider.ToLower() switch
|
||||||
|
{
|
||||||
|
"deezer" => _deezerMetadataService,
|
||||||
|
"qobuz" => _qobuzMetadataService,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a track to the playlist context cache.
|
||||||
|
/// This allows the download service to know which playlist a track belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public void AddTrackToPlaylistCache(string trackId, string playlistId)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.Add(CacheTTL);
|
||||||
|
_trackPlaylistCache[trackId] = (playlistId, expiresAt);
|
||||||
|
_logger.LogInformation("Added track {TrackId} to playlist cache with playlistId {PlaylistId}", trackId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the playlist ID for a given track ID from cache.
|
||||||
|
/// Returns null if not found or expired.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetPlaylistIdForTrack(string trackId)
|
||||||
|
{
|
||||||
|
if (_trackPlaylistCache.TryGetValue(trackId, out var entry))
|
||||||
|
{
|
||||||
|
if (entry.ExpiresAt > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return entry.PlaylistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired, remove it
|
||||||
|
_trackPlaylistCache.TryRemove(trackId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads all tracks from a playlist and creates an M3U file.
|
||||||
|
/// This is triggered when a user stars a playlist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DownloadFullPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting download for playlist {PlaylistId}", playlistId);
|
||||||
|
|
||||||
|
// Parse playlist ID
|
||||||
|
if (!PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
|
|
||||||
|
// Get playlist metadata
|
||||||
|
var metadataService = GetMetadataServiceForProvider(provider);
|
||||||
|
if (metadataService == null)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException($"Provider '{provider}' not supported for playlists");
|
||||||
|
}
|
||||||
|
|
||||||
|
var playlist = await metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Playlist not found: {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracks = await metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||||
|
if (tracks == null || tracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No tracks found in playlist {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {TrackCount} tracks in playlist '{PlaylistName}'", tracks.Count, playlist.Name);
|
||||||
|
|
||||||
|
// Get the appropriate download service for this provider
|
||||||
|
var downloadService = _downloadServices.FirstOrDefault(s =>
|
||||||
|
s.GetType().Name.Contains(provider, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (downloadService == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("No download service found for provider '{Provider}'", provider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download all tracks (M3U will be created once at the end)
|
||||||
|
var downloadedTracks = new List<(Song Song, string LocalPath)>();
|
||||||
|
|
||||||
|
foreach (var track in tracks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(track.ExternalId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Track has no external ID, skipping: {Title}", track.Title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track to playlist cache BEFORE downloading
|
||||||
|
// This marks it as part of a full playlist download, so AddTrackToM3UAsync will skip real-time updates
|
||||||
|
var trackId = $"ext-{provider}-{track.ExternalId}";
|
||||||
|
AddTrackToPlaylistCache(trackId, playlistId);
|
||||||
|
|
||||||
|
_logger.LogInformation("Downloading track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||||
|
var localPath = await downloadService.DownloadSongAsync(provider, track.ExternalId, cancellationToken);
|
||||||
|
|
||||||
|
downloadedTracks.Add((track, localPath));
|
||||||
|
_logger.LogDebug("Downloaded: {Path}", localPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadedTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No tracks were successfully downloaded for playlist '{PlaylistName}'", playlist.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create M3U file ONCE at the end with all downloaded tracks
|
||||||
|
await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks);
|
||||||
|
|
||||||
|
_logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'",
|
||||||
|
downloadedTracks.Count, tracks.Count, playlist.Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an M3U playlist file with relative paths to downloaded tracks
|
||||||
|
/// </summary>
|
||||||
|
private async Task CreateM3UPlaylistAsync(string playlistName, List<(Song Song, string LocalPath)> tracks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Sanitize playlist name for file system
|
||||||
|
var fileName = PathHelper.SanitizeFileName(playlistName) + ".m3u";
|
||||||
|
var playlistPath = Path.Combine(_playlistDirectory, fileName);
|
||||||
|
|
||||||
|
var m3uContent = new StringBuilder();
|
||||||
|
m3uContent.AppendLine("#EXTM3U");
|
||||||
|
|
||||||
|
foreach (var (song, localPath) in tracks)
|
||||||
|
{
|
||||||
|
// Calculate relative path from playlist directory to track
|
||||||
|
var relativePath = Path.GetRelativePath(_playlistDirectory, localPath);
|
||||||
|
|
||||||
|
// Convert backslashes to forward slashes for M3U compatibility
|
||||||
|
relativePath = relativePath.Replace('\\', '/');
|
||||||
|
|
||||||
|
// Add EXTINF line with duration and artist - title
|
||||||
|
var duration = song.Duration ?? 0;
|
||||||
|
m3uContent.AppendLine($"#EXTINF:{duration},{song.Artist} - {song.Title}");
|
||||||
|
m3uContent.AppendLine(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||||
|
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create M3U playlist for '{PlaylistName}'", playlistName);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a track to an existing M3U playlist or creates it if it doesn't exist.
|
||||||
|
/// Called when individual tracks are played/downloaded (NOT during full playlist download).
|
||||||
|
/// The M3U is rebuilt in the correct playlist order each time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isFullPlaylistDownload">If true, skips M3U update (will be done at the end by DownloadFullPlaylistAsync)</param>
|
||||||
|
public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath, bool isFullPlaylistDownload = false)
|
||||||
|
{
|
||||||
|
// Skip real-time updates during full playlist download (M3U will be created once at the end)
|
||||||
|
if (isFullPlaylistDownload)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get playlist metadata to get the name and track order
|
||||||
|
if (!PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
|
|
||||||
|
var metadataService = GetMetadataServiceForProvider(provider);
|
||||||
|
if (metadataService == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No metadata service found for provider '{Provider}'", provider);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var playlist = await metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Playlist not found: {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tracks from the playlist to maintain order
|
||||||
|
var allPlaylistTracks = await metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||||
|
if (allPlaylistTracks == null || allPlaylistTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No tracks found in playlist: {PlaylistId}", playlistId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize playlist name for file system
|
||||||
|
var fileName = PathHelper.SanitizeFileName(playlist.Name) + ".m3u";
|
||||||
|
var playlistPath = Path.Combine(_playlistDirectory, fileName);
|
||||||
|
|
||||||
|
// Build M3U content in the correct order
|
||||||
|
var m3uContent = new StringBuilder();
|
||||||
|
m3uContent.AppendLine("#EXTM3U");
|
||||||
|
|
||||||
|
int addedCount = 0;
|
||||||
|
foreach (var playlistTrack in allPlaylistTracks)
|
||||||
|
{
|
||||||
|
// Check if this track has been downloaded locally
|
||||||
|
string? trackLocalPath = null;
|
||||||
|
|
||||||
|
// If this is the track we just downloaded
|
||||||
|
if (playlistTrack.Id == track.Id)
|
||||||
|
{
|
||||||
|
trackLocalPath = localPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Check if track was previously downloaded
|
||||||
|
var trackProvider = playlistTrack.ExternalProvider;
|
||||||
|
var trackExternalId = playlistTrack.ExternalId;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(trackProvider) && !string.IsNullOrEmpty(trackExternalId))
|
||||||
|
{
|
||||||
|
// Try to find the download service for this provider
|
||||||
|
var downloadService = _downloadServices.FirstOrDefault(s =>
|
||||||
|
s.GetType().Name.Contains(trackProvider, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (downloadService != null)
|
||||||
|
{
|
||||||
|
trackLocalPath = await downloadService.GetLocalPathIfExistsAsync(trackProvider, trackExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If track is downloaded, add it to M3U
|
||||||
|
if (!string.IsNullOrEmpty(trackLocalPath) && IOFile.Exists(trackLocalPath))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(_playlistDirectory, trackLocalPath);
|
||||||
|
relativePath = relativePath.Replace('\\', '/');
|
||||||
|
|
||||||
|
var duration = playlistTrack.Duration ?? 0;
|
||||||
|
m3uContent.AppendLine($"#EXTINF:{duration},{playlistTrack.Artist} - {playlistTrack.Title}");
|
||||||
|
m3uContent.AppendLine(relativePath);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the M3U file (overwrites existing)
|
||||||
|
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||||
|
_logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
||||||
|
playlist.Name, addedCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to add track to M3U playlist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background task to clean up expired cache entries every minute
|
||||||
|
/// </summary>
|
||||||
|
private async Task CleanupExpiredCacheEntriesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var expiredKeys = _trackPlaylistCache
|
||||||
|
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var key in expiredKeys)
|
||||||
|
{
|
||||||
|
_trackPlaylistCache.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredKeys.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected when cancellation is requested
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error during playlist cache cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Playlist cache cleanup task stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the background cleanup task
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopCleanupAsync()
|
||||||
|
{
|
||||||
|
_cleanupCancellationTokenSource.Cancel();
|
||||||
|
await _cleanupTask;
|
||||||
|
_cleanupCancellationTokenSource.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using octo_fiesta.Models.Search;
|
using octo_fiesta.Models.Search;
|
||||||
|
using octo_fiesta.Models.Subsonic;
|
||||||
|
|
||||||
namespace octo_fiesta.Services.Subsonic;
|
namespace octo_fiesta.Services.Subsonic;
|
||||||
|
|
||||||
@@ -97,22 +98,23 @@ public class SubsonicModelMapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Merges local search results with external search results, deduplicating by name.
|
/// Merges local and external search results (songs, albums, artists, playlists).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResults(
|
public (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResults(
|
||||||
List<object> localSongs,
|
List<object> localSongs,
|
||||||
List<object> localAlbums,
|
List<object> localAlbums,
|
||||||
List<object> localArtists,
|
List<object> localArtists,
|
||||||
SearchResult externalResult,
|
SearchResult externalResult,
|
||||||
|
List<ExternalPlaylist> externalPlaylists,
|
||||||
bool isJson)
|
bool isJson)
|
||||||
{
|
{
|
||||||
if (isJson)
|
if (isJson)
|
||||||
{
|
{
|
||||||
return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult);
|
return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult, externalPlaylists);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult);
|
return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult, externalPlaylists);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,14 +122,17 @@ public class SubsonicModelMapper
|
|||||||
List<object> localSongs,
|
List<object> localSongs,
|
||||||
List<object> localAlbums,
|
List<object> localAlbums,
|
||||||
List<object> localArtists,
|
List<object> localArtists,
|
||||||
SearchResult externalResult)
|
SearchResult externalResult,
|
||||||
|
List<ExternalPlaylist> externalPlaylists)
|
||||||
{
|
{
|
||||||
var mergedSongs = localSongs
|
var mergedSongs = localSongs
|
||||||
.Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s)))
|
.Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Merge albums with playlists (playlists appear as albums with genre "Playlist")
|
||||||
var mergedAlbums = localAlbums
|
var mergedAlbums = localAlbums
|
||||||
.Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a)))
|
.Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a)))
|
||||||
|
.Concat(externalPlaylists.Select(p => ConvertPlaylistToAlbumJson(p)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Deduplicate artists by name - prefer local artists over external ones
|
// Deduplicate artists by name - prefer local artists over external ones
|
||||||
@@ -157,7 +162,8 @@ public class SubsonicModelMapper
|
|||||||
List<object> localSongs,
|
List<object> localSongs,
|
||||||
List<object> localAlbums,
|
List<object> localAlbums,
|
||||||
List<object> localArtists,
|
List<object> localArtists,
|
||||||
SearchResult externalResult)
|
SearchResult externalResult,
|
||||||
|
List<ExternalPlaylist> externalPlaylists)
|
||||||
{
|
{
|
||||||
var ns = XNamespace.Get("http://subsonic.org/restapi");
|
var ns = XNamespace.Get("http://subsonic.org/restapi");
|
||||||
|
|
||||||
@@ -196,6 +202,11 @@ public class SubsonicModelMapper
|
|||||||
{
|
{
|
||||||
mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns));
|
mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns));
|
||||||
}
|
}
|
||||||
|
// Add playlists as albums
|
||||||
|
foreach (var playlist in externalPlaylists)
|
||||||
|
{
|
||||||
|
mergedAlbums.Add(ConvertPlaylistToAlbumXml(playlist, ns));
|
||||||
|
}
|
||||||
|
|
||||||
// Songs
|
// Songs
|
||||||
var mergedSongs = new List<object>();
|
var mergedSongs = new List<object>();
|
||||||
@@ -211,4 +222,81 @@ public class SubsonicModelMapper
|
|||||||
|
|
||||||
return (mergedSongs, mergedAlbums, mergedArtists);
|
return (mergedSongs, mergedAlbums, mergedArtists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an ExternalPlaylist to a JSON object representing an album.
|
||||||
|
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
|
||||||
|
/// </summary>
|
||||||
|
private Dictionary<string, object> ConvertPlaylistToAlbumJson(ExternalPlaylist playlist)
|
||||||
|
{
|
||||||
|
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||||
|
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||||
|
{
|
||||||
|
artistName += $" {playlist.CuratorName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||||
|
|
||||||
|
var album = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["id"] = playlist.Id,
|
||||||
|
["name"] = playlist.Name,
|
||||||
|
["artist"] = artistName,
|
||||||
|
["artistId"] = artistId,
|
||||||
|
["genre"] = "Playlist",
|
||||||
|
["songCount"] = playlist.TrackCount,
|
||||||
|
["duration"] = playlist.Duration
|
||||||
|
};
|
||||||
|
|
||||||
|
if (playlist.CreatedDate.HasValue)
|
||||||
|
{
|
||||||
|
album["year"] = playlist.CreatedDate.Value.Year;
|
||||||
|
album["created"] = playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(playlist.CoverUrl))
|
||||||
|
{
|
||||||
|
album["coverArt"] = playlist.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an ExternalPlaylist to an XML element representing an album.
|
||||||
|
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
|
||||||
|
/// </summary>
|
||||||
|
private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns)
|
||||||
|
{
|
||||||
|
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||||
|
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||||
|
{
|
||||||
|
artistName += $" {playlist.CuratorName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||||
|
|
||||||
|
var album = new XElement(ns + "album",
|
||||||
|
new XAttribute("id", playlist.Id),
|
||||||
|
new XAttribute("name", playlist.Name),
|
||||||
|
new XAttribute("artist", artistName),
|
||||||
|
new XAttribute("artistId", artistId),
|
||||||
|
new XAttribute("genre", "Playlist"),
|
||||||
|
new XAttribute("songCount", playlist.TrackCount),
|
||||||
|
new XAttribute("duration", playlist.Duration)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playlist.CreatedDate.HasValue)
|
||||||
|
{
|
||||||
|
album.Add(new XAttribute("year", playlist.CreatedDate.Value.Year));
|
||||||
|
album.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(playlist.CoverUrl))
|
||||||
|
{
|
||||||
|
album.Add(new XAttribute("coverArt", playlist.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return album;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using octo_fiesta.Models.Domain;
|
using octo_fiesta.Models.Domain;
|
||||||
|
using octo_fiesta.Models.Subsonic;
|
||||||
|
|
||||||
namespace octo_fiesta.Services.Subsonic;
|
namespace octo_fiesta.Services.Subsonic;
|
||||||
|
|
||||||
@@ -137,6 +138,81 @@ public class SubsonicResponseBuilder
|
|||||||
);
|
);
|
||||||
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
|
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a Subsonic response for a playlist represented as an album.
|
||||||
|
/// Playlists appear as albums with genre "Playlist".
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult CreatePlaylistAsAlbumResponse(string format, ExternalPlaylist playlist, List<Song> tracks)
|
||||||
|
{
|
||||||
|
var totalDuration = tracks.Sum(s => s.Duration ?? 0);
|
||||||
|
|
||||||
|
// Build artist name with emoji and curator
|
||||||
|
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||||
|
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||||
|
{
|
||||||
|
artistName += $" {playlist.CuratorName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||||
|
|
||||||
|
if (format == "json")
|
||||||
|
{
|
||||||
|
return CreateJsonResponse(new
|
||||||
|
{
|
||||||
|
status = "ok",
|
||||||
|
version = SubsonicVersion,
|
||||||
|
album = new
|
||||||
|
{
|
||||||
|
id = playlist.Id,
|
||||||
|
name = playlist.Name,
|
||||||
|
artist = artistName,
|
||||||
|
artistId = artistId,
|
||||||
|
coverArt = playlist.Id,
|
||||||
|
songCount = tracks.Count,
|
||||||
|
duration = totalDuration,
|
||||||
|
year = playlist.CreatedDate?.Year ?? 0,
|
||||||
|
genre = "Playlist",
|
||||||
|
isCompilation = false,
|
||||||
|
created = playlist.CreatedDate?.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||||
|
song = tracks.Select(s => ConvertSongToJson(s)).ToList()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var ns = XNamespace.Get(SubsonicNamespace);
|
||||||
|
var albumElement = new XElement(ns + "album",
|
||||||
|
new XAttribute("id", playlist.Id),
|
||||||
|
new XAttribute("name", playlist.Name),
|
||||||
|
new XAttribute("artist", artistName),
|
||||||
|
new XAttribute("artistId", artistId),
|
||||||
|
new XAttribute("songCount", tracks.Count),
|
||||||
|
new XAttribute("duration", totalDuration),
|
||||||
|
new XAttribute("genre", "Playlist"),
|
||||||
|
new XAttribute("coverArt", playlist.Id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playlist.CreatedDate.HasValue)
|
||||||
|
{
|
||||||
|
albumElement.Add(new XAttribute("year", playlist.CreatedDate.Value.Year));
|
||||||
|
albumElement.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add songs
|
||||||
|
foreach (var song in tracks)
|
||||||
|
{
|
||||||
|
albumElement.Add(ConvertSongToXml(song, ns));
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = new XDocument(
|
||||||
|
new XElement(ns + "subsonic-response",
|
||||||
|
new XAttribute("status", "ok"),
|
||||||
|
new XAttribute("version", SubsonicVersion),
|
||||||
|
albumElement
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a Subsonic response containing an artist with albums.
|
/// Creates a Subsonic response containing an artist with albums.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"Subsonic": {
|
"Subsonic": {
|
||||||
"Url": "https://navidrome.local.bransonb.com",
|
"Url": "https://navidrome.local.bransonb.com",
|
||||||
"MusicService": "SquidWTF"
|
"MusicService": "SquidWTF"
|
||||||
|
"ExplicitFilter": "All",
|
||||||
|
"DownloadMode": "Track",
|
||||||
|
"StorageMode": "Permanent",
|
||||||
|
"CacheDurationHours": 1
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads"
|
||||||
@@ -20,4 +24,4 @@
|
|||||||
"Quality": "FLAC"
|
"Quality": "FLAC"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user