diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..d85be7b --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,49 @@ +name: Build for Windows + +on: + push: + branches: [ "main", "feature/cross-platform" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + target: x86_64-pc-windows-msvc + + - name: Install Qt 6 + uses: jurplel/install-qt-action@v4 + with: + version: '6.5.3' + host: 'windows' + target: 'desktop' + arch: 'win64_msvc2019_64' + + - name: Configure CMake + run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release + + - name: Build Application + run: cmake --build build --config Release -j %NUMBER_OF_PROCESSORS% + + - name: Bundle Executable (windeployqt) + run: | + mkdir release + copy build\Release\qobuz-qt.exe release\ + windeployqt release\qobuz-qt.exe + shell: cmd + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: qobuz-qt-windows-x64 + path: release/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 3af4a33..a7c3e39 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,7 +38,11 @@ else() set(CARGO_PROFILE_FLAG "") endif() -set(RUST_LIB "${CMAKE_SOURCE_DIR}/target/${CARGO_PROFILE}/libqobuz_backend.a") +if(WIN32 AND MSVC) + set(RUST_LIB "${CMAKE_SOURCE_DIR}/target/${CARGO_PROFILE}/qobuz_backend.lib") +else() + set(RUST_LIB "${CMAKE_SOURCE_DIR}/target/${CARGO_PROFILE}/libqobuz_backend.a") +endif() add_custom_target(rust_backend ALL COMMAND ${CARGO_CMD} build ${CARGO_PROFILE_FLAG} @@ -83,23 +87,37 @@ target_link_libraries(qobuz-qt PRIVATE qobuz_backend_lib ) -# Platform-specific system libs needed by the Rust audio stack (cpal/ALSA) +# Platform-specific system libs needed by the Rust audio stack (cpal/backend internals) if (UNIX AND NOT APPLE) target_link_libraries(qobuz-qt PRIVATE asound) +elseif (APPLE) + find_library(COREAUDIO CoreAudio) + find_library(AUDIOTOOLBOX AudioToolbox) + find_library(SECURITY Security) + find_library(COREFOUNDATION CoreFoundation) + target_link_libraries(qobuz-qt PRIVATE ${COREAUDIO} ${AUDIOTOOLBOX} ${SECURITY} ${COREFOUNDATION}) +elseif (WIN32) + target_link_libraries(qobuz-qt PRIVATE ws2_32 userenv bcrypt advapi32 ntdll ole32) endif () # Compiler warnings + hardening if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang") target_compile_options(qobuz-qt PRIVATE -Wall -Wextra -Wno-unused-parameter - -fstack-protector-strong - -D_FORTIFY_SOURCE=2 - -fPIE - ) - target_link_options(qobuz-qt PRIVATE - -pie - -Wl,-z,relro,-z,now ) + if (UNIX AND NOT APPLE) + target_compile_options(qobuz-qt PRIVATE + -fstack-protector-strong + -D_FORTIFY_SOURCE=2 + -fPIE + ) + target_link_options(qobuz-qt PRIVATE + -pie + -Wl,-z,relro,-z,now + ) + endif () +elseif (MSVC) + target_compile_options(qobuz-qt PRIVATE /W3 /Zc:__cplusplus /permissive-) endif () # D-Bus @@ -118,7 +136,7 @@ if (USE_LTO) endif () # Install -if (UNIX) +if (UNIX AND NOT APPLE) install(FILES res/logo/qobuz-qt.svg DESTINATION share/icons/hicolor/scalable/apps) install(FILES res/app/qobuz-qt.desktop DESTINATION share/applications) install(TARGETS qobuz-qt RUNTIME DESTINATION bin) diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..7a0dc87 --- /dev/null +++ b/openapi.json @@ -0,0 +1,7365 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Qobuz API", + "description": "Unofficial Qobuz API documentation based on observed endpoints", + "version": "0.2.0" + }, + "servers": [ + { + "url": "https://www.qobuz.com/api.json/0.2", + "description": "Production API server" + } + ], + "paths": { + "/album/get": { + "get": { + "summary": "Get album details", + "description": "Retrieve detailed information about a specific album", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "album_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Album ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Album" + }, + "example": { + "id": "0000000000", + "title": "Album Title", + "artist": { + "id": "0000000", + "name": "Artist Name" + }, + "image": { + "small": "https://static.qobuz.com/images/covers/..._small.jpg", + "medium": "https://static.qobuz.com/images/covers/..._medium.jpg", + "large": "https://static.qobuz.com/images/covers/..._large.jpg", + "extralarge": "https://static.qobuz.com/images/covers/..._extralarge.jpg" + }, + "release_date": "2023-01-01", + "duration": 3600, + "tracks_count": 12, + "media_count": 1, + "genre": { + "id": "000", + "name": "Rock" + }, + "label": { + "id": "00000", + "name": "Label Name" + }, + "copyright": "© 2023 Label Name", + "is_preorderable": false, + "is_streamable": true, + "is_purchasable": true, + "is_super_high_res": false, + "is_high_res": true, + "is_lossless": true, + "is_mp3": true + } + } + } + } + } + } + }, + "/album/search": { + "get": { + "summary": "Search albums", + "description": "Search for albums by query with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 500 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlbumSearchResult" + } + } + } + } + ] + }, + "example": { + "has_more": false, + "items": [ + { + "id": "0000000000", + "title": "Album Title", + "artist": { + "id": "0000000", + "name": "Artist Name" + }, + "image": { + "small": "https://static.qobuz.com/images/covers/..._small.jpg" + }, + "release_date": "2023-01-01", + "streamable": true, + "duration": 3600, + "tracks_count": 12 + } + ], + "total": 1, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/album/story": { + "get": { + "summary": "Get album story", + "description": "Retrieve story or editorial content for an album", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "album_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Album ID" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + }, + "example": { + "has_more": false, + "items": [], + "total": 0, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/album/suggest": { + "get": { + "summary": "Get album suggestions", + "description": "Get suggested albums based on a seed album", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "album_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Seed album ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlbumSuggestion" + }, + "example": { + "algorithm": "similar_albums", + "albums": [ + { + "id": "0000000000", + "title": "Suggested Album", + "artist": { + "id": "0000000", + "name": "Artist Name" + }, + "image": { + "small": "https://static.qobuz.com/images/covers/..._small.jpg", + "medium": "https://static.qobuz.com/images/covers/..._medium.jpg", + "large": "https://static.qobuz.com/images/covers/..._large.jpg", + "extralarge": "https://static.qobuz.com/images/covers/..._extralarge.jpg" + }, + "release_date": "2023-01-01", + "duration": 3600, + "tracks_count": 12, + "media_count": 1, + "genre": { + "id": "000", + "name": "Rock" + }, + "label": { + "id": "00000", + "name": "Label Name" + }, + "copyright": "© 2023 Label Name", + "is_preorderable": false, + "is_streamable": true, + "is_purchasable": true, + "is_super_high_res": false, + "is_high_res": true, + "is_lossless": true, + "is_mp3": true + } + ] + } + } + } + } + } + } + }, + "/artist/getImage": { + "post": { + "summary": "Get artist images", + "description": "Retrieve image metadata for one or more artists", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtistImageRequest" + }, + "example": { + "artist_ids": ["0000000", "0000001", "0000002"] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArtistImage" + } + }, + "example": [ + { + "artist_id": "0000000", + "image_url": "https://static.qobuz.com/images/artists/...jpg", + "width": 500, + "height": 500, + "format": "jpg" + } + ] + } + } + } + } + } + }, + "/artist/getReleasesList": { + "get": { + "summary": "Get artist releases list", + "description": "Retrieve paginated list of releases (albums, singles, EPs) for an artist", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "artist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Artist ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 500 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "release_type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["album", "single", "ep"] + }, + "description": "Filter by release type" + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["release_date", "popularity", "title"] + }, + "description": "Sort field" + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["desc", "asc"] + }, + "description": "Sort order" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": "o4qk5j2xzuvlb", + "title": "All Stand Together", + "version": "Deluxe", + "tracks_count": 12, + "artist": { + "id": 1863897, + "name": { + "display": "Lost Frequencies" + } + }, + "artists": [ + { + "id": 1863897, + "name": "Lost Frequencies", + "roles": ["main-artist"] + } + ], + "image": { + "small": "https://static.qobuz.com/images/covers/lb/uv/o4qk5j2xzuvlb_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/lb/uv/o4qk5j2xzuvlb_50.jpg", + "large": "https://static.qobuz.com/images/covers/lb/uv/o4qk5j2xzuvlb_600.jpg" + }, + "label": { + "id": 85930, + "name": "Epic Amsterdam" + }, + "genre": { + "id": 117, + "name": "Pop", + "path": [112, 117] + }, + "release_type": "album", + "release_tags": ["deluxe"], + "duration": 2947, + "dates": { + "download": "2023-11-10", + "original": "2023-11-10", + "stream": "2023-11-10" + }, + "parental_warning": false, + "audio_info": { + "maximum_bit_depth": 24, + "maximum_channel_count": 2, + "maximum_sampling_rate": 44.1 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + }, + "awards": [], + "tracks": { + "has_more": true, + "items": [] + } + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/artist/getSimilarArtists": { + "get": { + "summary": "Get similar artists", + "description": "Retrieve artists similar to a given artist with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "artist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Artist ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 15, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimilarArtistsResponse" + }, + "example": { + "artists": { + "limit": 15, + "offset": 0, + "total": 48, + "items": [ + { + "id": 1280691, + "name": "Klingande", + "slug": "klingande", + "albums_count": 228, + "picture": "https://static.qobuz.com/images/artists/covers/large/2bdf046c757ab6a976e62406f175b910.jpg", + "image": { + "small": "https://static.qobuz.com/images/artists/covers/small/2bdf046c757ab6a976e62406f175b910.jpg", + "medium": "https://static.qobuz.com/images/artists/covers/medium/2bdf046c757ab6a976e62406f175b910.jpg", + "large": "https://static.qobuz.com/images/artists/covers/large/2bdf046c757ab6a976e62406f175b910.jpg", + "extralarge": "https://static.qobuz.com/images/artists/covers/large/2bdf046c757ab6a976e62406f175b910.jpg", + "mega": "https://static.qobuz.com/images/artists/covers/large/2bdf046c757ab6a976e62406f175b910.jpg" + } + } + ] + } + } + } + } + } + } + } + }, + "/artist/page": { + "get": { + "summary": "Get artist page", + "description": "Retrieve detailed artist page including biography, similar artists, and top tracks", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "artist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Artist ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtistPage" + }, + "example": { + "id": 1863897, + "name": { + "display": "Lost Frequencies" + }, + "artist_category": "performer", + "biography": { + "content": "Belgian producer Lost Frequencies has achieved massive international success...", + "source": null, + "language": "en" + }, + "images": { + "portrait": { + "hash": "77817762dd3688191f752509901e4338", + "format": "jpg" + } + }, + "similar_artists": { + "has_more": true, + "items": [ + { + "id": 1280691, + "name": { + "display": "Klingande" + }, + "images": { + "portrait": { + "hash": "2bdf046c757ab6a976e62406f175b910", + "format": "jpg" + } + } + } + ] + }, + "top_tracks": [ + { + "id": 256172621, + "isrc": "BEHP42400010", + "title": "No Limit", + "artist": { + "id": 1863897, + "name": { + "display": "Lost Frequencies" + } + }, + "album": { + "id": "o4qk5j2xzuvlb", + "title": "All Stand Together", + "image": { + "small": "https://static.qobuz.com/images/covers/lb/uv/o4qk5j2xzuvlb_230.jpg" + } + } + } + ] + } + } + } + } + } + } + }, + "/artist/search": { + "get": { + "summary": "Search artists", + "description": "Search for artists by query with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 8, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtistSearchResponse" + }, + "example": { + "query": "lost frequencies", + "artists": { + "limit": 8, + "offset": 0, + "analytics": { + "search_external_id": "2ee9f2c093a393cb875db77518c52fbe" + }, + "total": 6, + "items": [ + { + "picture": "https://static.qobuz.com/images/artists/covers/small/77817762dd3688191f752509901e4338.jpg", + "image": { + "small": "https://static.qobuz.com/images/artists/covers/small/77817762dd3688191f752509901e4338.jpg", + "medium": "https://static.qobuz.com/images/artists/covers/medium/77817762dd3688191f752509901e4338.jpg", + "large": "https://static.qobuz.com/images/artists/covers/large/77817762dd3688191f752509901e4338.jpg", + "extralarge": "https://static.qobuz.com/images/artists/covers/large/77817762dd3688191f752509901e4338.jpg", + "mega": "https://static.qobuz.com/images/artists/covers/large/77817762dd3688191f752509901e4338.jpg" + }, + "name": "Lost Frequencies", + "slug": "lost-frequencies", + "albums_count": 990, + "id": 1863897 + } + ] + } + } + } + } + } + } + } + }, + "/artist/story": { + "get": { + "summary": "Get artist story", + "description": "Retrieve story or editorial content for an artist", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "artist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Artist ID" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 5 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + }, + "example": { + "has_more": false, + "items": [], + "total": 0, + "limit": 5, + "offset": 0 + } + } + } + } + } + } + }, + "/award/getAlbums": { + "get": { + "summary": "Get albums by award", + "description": "Retrieve albums that have received a specific award with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "award_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Award ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": "0060252772410", + "title": "Lungs", + "version": "Deluxe Version", + "track_count": 20, + "duration": 4066, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/10/24/0060252772410_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/10/24/0060252772410_50.jpg", + "large": "https://static.qobuz.com/images/covers/10/24/0060252772410_600.jpg" + }, + "artists": [ + { + "id": 112890, + "name": "Florence + The Machine", + "roles": ["main-artist"] + } + ], + "label": { + "id": 17426, + "name": "Universal-Island Records Ltd." + }, + "genre": { + "id": 113, + "name": "Alternativa & Indie", + "path": [112, 119, 113] + }, + "dates": { + "download": "2009-07-06", + "original": "2009-07-06", + "purchase": "2009-07-06", + "stream": "2009-07-06" + }, + "awards": [ + { + "id": 70, + "name": "Discoteca Ideal Qobuz", + "awarded_at": "2026-02-03" + } + ], + "audio_info": { + "maximum_sampling_rate": 44.1, + "maximum_bit_depth": 16, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": false, + "hires_purchasable": false + } + } + ], + "total": 100, + "limit": 25, + "offset": 0 + } + } + } + } + } + } + }, + "/discover/index": { + "get": { + "summary": "Get discover index", + "description": "Retrieve discover page content including banners and new releases", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "genre_ids", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated genre IDs to filter by" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscoverContainer" + }, + "example": { + "containers": { + "banners": { + "id": "banners", + "data": { + "has_more": false, + "items": [] + } + }, + "new_releases": { + "id": "newReleases", + "data": { + "has_more": true, + "items": [ + { + "id": "xxc24b1r0wt8q", + "title": "i-330", + "version": null, + "track_count": 12, + "duration": 2764, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_50.jpg", + "large": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_600.jpg" + }, + "artists": [ + { + "id": 28885011, + "name": "Flore Benguigui & The Sensible Notes", + "roles": ["main-artist"] + } + ], + "label": { + "id": 108875, + "name": "Universal Music Division Decca Records France" + }, + "genre": { + "id": 89, + "name": "Jazz vocal", + "path": [80, 89] + }, + "dates": { + "download": "2026-03-13", + "original": "2026-03-13", + "purchase": "2026-03-13", + "stream": "2026-03-13" + }, + "awards": [ + { + "id": 88, + "name": "Qobuzissime", + "awarded_at": "2026-03-20" + } + ], + "audio_info": { + "maximum_sampling_rate": 48, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + } + } + } + }, + "/discover/mostStreamed": { + "get": { + "summary": "Get most streamed albums", + "description": "Retrieve most streamed albums with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": "x165phie4qqlc", + "title": "For Mary", + "version": null, + "track_count": 12, + "duration": 2764, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/lc/qq/x165phie4qqlc_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/lc/qq/x165phie4qqlc_50.jpg", + "large": "https://static.qobuz.com/images/covers/lc/qq/x165phie4qqlc_600.jpg" + }, + "artists": [ + { + "id": 18377461, + "name": "Olive Jones", + "roles": ["main-artist"] + } + ], + "label": { + "id": 2367808, + "name": "Nettwerk Music Group" + }, + "genre": { + "id": 134, + "name": "Soul", + "path": [127, 134] + }, + "dates": { + "download": "2026-03-13", + "original": "2026-03-13", + "purchase": "2026-03-13", + "stream": "2026-03-13" + }, + "awards": [ + { + "id": 88, + "name": "Qobuzissime", + "awarded_at": "2026-03-13" + } + ], + "audio_info": { + "maximum_sampling_rate": 44.1, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/discover/newReleases": { + "get": { + "summary": "Get new releases", + "description": "Retrieve newly released albums with pagination, optionally filtered by genre", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "genre_ids", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated genre IDs to filter by" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": "xxc24b1r0wt8q", + "title": "i-330", + "version": null, + "track_count": 12, + "duration": 2764, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_50.jpg", + "large": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_600.jpg" + }, + "artists": [ + { + "id": 28885011, + "name": "Flore Benguigui & The Sensible Notes", + "roles": ["main-artist"] + } + ], + "label": { + "id": 108875, + "name": "Universal Music Division Decca Records France" + }, + "genre": { + "id": 89, + "name": "Jazz vocal", + "path": [80, 89] + }, + "dates": { + "download": "2026-03-13", + "original": "2026-03-13", + "purchase": "2026-03-13", + "stream": "2026-03-13" + }, + "awards": [ + { + "id": 88, + "name": "Qobuzissime", + "awarded_at": "2026-03-20" + } + ], + "audio_info": { + "maximum_sampling_rate": 48, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/discover/playlists": { + "get": { + "summary": "Get discover playlists", + "description": "Retrieve curated playlists for discovery, optionally filtered by genre or tags", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "genre_ids", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated genre IDs to filter by" + }, + { + "name": "tags", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Tags to filter by (e.g., 'hi-res')" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Playlist" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": 7801943, + "name": "Lo último en Rock", + "owner": { + "id": 1752421, + "name": "Qobuz Latinoamérica" + }, + "image": { + "rectangle": "https://static.qobuz.com/images/playlists/7801943_cb084b9bc2b9e578e26ed41384465fe7_rectangle.jpg", + "covers": [ + "https://static.qobuz.com/images/covers/98/g5/e644dwd1dg598_300.jpg", + "https://static.qobuz.com/images/covers/nf/p6/nbbgfku1fp6nf_300.jpg", + "https://static.qobuz.com/images/covers/70/o9/ywkqfaouyo970_300.jpg", + "https://static.qobuz.com/images/covers/17/kc/cxdas1z89kc17_300.jpg" + ] + }, + "description": "Con Muse, Foo Fighters, Peter Frampton, The Black Keys, Angine de Poitrine, Dropkick Murphys, The Afghan Whigs, Tedeschi Trucks Band, The Black Crowes, Glen Hansard, DOGSTAR, The Lemon Twigs, The Pretty Reckless, Violet Grohl, Iceage, Yonaka, The Warning, Oasis, The Sleeveens, Talking Heads, Queen, Black Stone Cherry, Mclusky, Pacific Avenue, Social Distorsion...\n\nÚltima actualización el 23 de marzo de 2026\n\nFoto: The Black Crowes © Ross Halfin", + "duration": 17779, + "tracks_count": 80, + "genres": [ + { + "id": 112, + "name": "Pop/Rock", + "path": [112] + } + ], + "tags": [ + { + "id": 19, + "slug": "new", + "name": "Novedades" + } + ] + } + ], + "total": 100, + "limit": 25, + "offset": 0 + } + } + } + } + } + } + }, + "/discover/pressAward": { + "get": { + "summary": "Get press award albums", + "description": "Retrieve albums that have received press awards (e.g., Pitchfork: Best New Music) with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": "xxc24b1r0wt8q", + "title": "i-330", + "version": null, + "track_count": 12, + "duration": 2764, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_50.jpg", + "large": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_600.jpg" + }, + "artists": [ + { + "id": 28885011, + "name": "Flore Benguigui & The Sensible Notes", + "roles": ["main-artist"] + } + ], + "label": { + "id": 108875, + "name": "Universal Music Division Decca Records France" + }, + "genre": { + "id": 89, + "name": "Jazz vocal", + "path": [80, 89] + }, + "dates": { + "download": "2026-03-13", + "original": "2026-03-13", + "purchase": "2026-03-13", + "stream": "2026-03-13" + }, + "awards": [ + { + "id": 89, + "name": "Pitchfork: Best New Music", + "awarded_at": "2026-03-20" + } + ], + "audio_info": { + "maximum_sampling_rate": 48, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 100, + "limit": 25, + "offset": 0 + } + } + } + } + } + } + }, + "/dynamic/suggest": { + "post": { + "summary": "Get dynamic track suggestions", + "description": "Generate track recommendations based on listening history", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["tracks"], + "properties": { + "tracks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Track ID" + }, + "play_count": { + "type": "integer", + "description": "Number of times track was played" + }, + "last_played": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of last play" + } + }, + "required": ["id"] + }, + "description": "List of recently played tracks" + } + } + }, + "example": { + "tracks": [ + { + "id": "256172621", + "play_count": 5, + "last_played": "2026-03-25T12:00:00Z" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tracks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Track ID" + }, + "title": { + "type": "string", + "description": "Track title" + }, + "artist": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "album": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + } + } + } + } + }, + "duration": { + "type": "integer", + "description": "Duration in seconds" + }, + "isrc": { + "type": "string", + "description": "ISRC code" + }, + "preview_url": { + "type": "string", + "format": "uri", + "description": "URL to track preview" + } + } + }, + "description": "Recommended tracks" + } + } + }, + "example": { + "tracks": [ + { + "id": "256172622", + "title": "No Limit", + "artist": { + "id": 1863897, + "name": "Lost Frequencies" + }, + "album": { + "id": "o4qk5j2xzuvlb", + "title": "All Stand Together", + "image": { + "small": "https://static.qobuz.com/images/covers/lb/uv/o4qk5j2xzuvlb_230.jpg" + } + }, + "duration": 214, + "isrc": "BEHP42400010", + "preview_url": "https://preview.qobuz.com/track/256172622" + } + ] + } + } + } + } + } + } + }, + "/event/reportTrackContext": { + "post": { + "summary": "Report track context event", + "description": "Send analytics event for track playback context (e.g., playlist, album, radio)", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["track_id", "context"], + "properties": { + "track_id": { + "type": "string", + "description": "Track ID" + }, + "context": { + "type": "string", + "description": "Context where track was played (e.g., 'playlist', 'album', 'radio', 'search')" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Event timestamp (ISO 8601)" + }, + "event_type": { + "type": "string", + "enum": ["play", "pause", "skip", "complete"], + "description": "Type of playback event" + } + } + }, + "example": { + "track_id": "256172621", + "context": "playlist", + "timestamp": "2026-03-25T12:00:00Z", + "event_type": "play" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + }, + "example": { + "success": true + } + } + } + } + } + } + }, + "/favorite/create": { + "post": { + "summary": "Create favorite", + "description": "Add an item (track, album, artist, playlist) to user's favorites", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["track", "album", "artist", "playlist"], + "description": "Type of item to favorite" + }, + "id": { + "type": "string", + "description": "ID of the item" + } + } + }, + "example": { + "type": "track", + "id": "256172621" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + }, + "example": { + "success": true + } + } + } + } + } + } + }, + "/favorite/delete": { + "post": { + "summary": "Delete favorite", + "description": "Remove an item from user's favorites", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["track", "album", "artist", "playlist"], + "description": "Type of item to unfavorite" + }, + "id": { + "type": "string", + "description": "ID of the item" + } + } + }, + "example": { + "type": "track", + "id": "256172621" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + } + }, + "example": { + "success": true + } + } + } + } + } + } + }, + "/favorite/getNewReleases": { + "get": { + "summary": "Get new releases from favorites", + "description": "Retrieve new releases from favorite artists, albums, or tracks with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["artists", "albums", "tracks"], + "default": "artists" + }, + "description": "Type of favorites to get new releases for" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + }, + "example": { + "has_more": false, + "items": [ + { + "id": "b9iimunju35gy", + "title": "So Much Beauty (Around Us)", + "version": null, + "track_count": 1, + "duration": 154, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/gy/35/b9iimunju35gy_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/gy/35/b9iimunju35gy_50.jpg", + "large": "https://static.qobuz.com/images/covers/gy/35/b9iimunju35gy_600.jpg" + }, + "artists": [ + { + "id": 1863897, + "name": "Lost Frequencies", + "roles": ["main-artist"] + }, + { + "id": 613922, + "name": "Nathan Nicholson", + "roles": ["main-artist"] + } + ], + "label": { + "id": 85930, + "name": "Epic Amsterdam" + }, + "genre": { + "id": 68, + "name": "House", + "path": [64, 68] + }, + "dates": { + "download": "2026-03-20", + "original": "2026-03-20", + "purchase": "2026-03-20", + "stream": "2026-03-20" + }, + "awards": [], + "audio_info": { + "maximum_sampling_rate": 44.1, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 1, + "limit": 25, + "offset": 0 + } + } + } + } + } + } + }, + "/favorite/getUserFavoriteIds": { + "get": { + "summary": "Get user favorite IDs", + "description": "Retrieve IDs of user's favorite items by type (albums, artists, tracks, etc.)", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "albums": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of favorite album IDs" + }, + "articles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of favorite article IDs" + }, + "artists": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Array of favorite artist IDs" + }, + "awards": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of favorite award IDs" + }, + "tracks": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Array of favorite track IDs" + }, + "labels": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Array of favorite label IDs" + } + } + }, + "example": { + "albums": ["ftpnmecvoyiac", "xxc24b1r0wt8q", "byz76ojp231db"], + "articles": [], + "artists": [1863897, 2458997, 3722442, 267547], + "awards": [], + "tracks": [35883373, 350020, 246342230, 123287900, 129183843], + "labels": [] + } + } + } + } + } + } + }, + "/file": { + "get": { + "summary": "Get audio file", + "description": "Stream audio file segment (used internally by streaming URLs)", + "parameters": [ + { + "name": "uid", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "eid", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "fmt", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "fid", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "profile", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "s", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "app_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cid", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "etsp", + "in": "query", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "hmac", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Audio file segment", + "content": { + "audio/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/file/url": { + "get": { + "summary": "Get file URL", + "description": "Retrieve streaming URL and metadata for a track", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "track_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Track ID" + }, + { + "name": "format_id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "enum": [5, 6, 7, 27] + }, + "description": "Audio format ID (5=MP3 320, 6=Lossless, 7=Hi-Res, 27=Hi-Res 2)" + }, + { + "name": "intent", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": ["stream", "import", "download"] + }, + "description": "Intended use of the file" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "file_type": { + "type": "string", + "enum": ["full", "preview"] + }, + "track_id": { + "type": "integer" + }, + "format_id": { + "type": "integer" + }, + "audio_file_id": { + "type": "integer" + }, + "sampling_rate": { + "type": "integer" + }, + "bits_depth": { + "type": "integer" + }, + "n_channels": { + "type": "integer" + }, + "duration": { + "type": "number" + }, + "n_samples": { + "type": "integer" + }, + "mime_type": { + "type": "string" + }, + "url_template": { + "type": "string", + "format": "uri" + }, + "n_segments": { + "type": "integer" + }, + "key_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "restrictions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + } + } + }, + "blob": { + "type": "string" + } + } + }, + "example": { + "file_type": "full", + "track_id": 378123655, + "format_id": 7, + "audio_file_id": 329231102, + "sampling_rate": 48000, + "bits_depth": 24, + "n_channels": 2, + "duration": 52.693333333333335, + "n_samples": 2529280, + "mime_type": "audio/mp4; codecs=\"flac\"", + "url_template": "https://streaming-qobuz-sec.akamaized.net/file?uid=8029441&eid=378123655&fmt=7&fid=329231102&profile=sec-1&s=$SEGMENT$&app_id=312369995&cid=3261622&etsp=1774634583&hmac=ieIWGzt2mjgwTA7jjycdZUVgI78", + "n_segments": 6, + "key_id": "f9596d1d-d4ff-b84b-255b-37768aa62f25", + "key": "qbz-1.wi1Dfv-w70hjD9jLn4goT58s1_WkSzae2GW4u4cwwi8.DamdSDikJiPWBeVLoFimHQ", + "blob": "100000.Bxl-2gCMtO5WmHFoHsVzzMSK2DkupYrYWI9YoJS-uhfcFZO_rQcSUMSOkgJLulBP.rdDUaxiiyKAs1t-4J_dFYn5sjlk" + } + } + } + } + } + } + }, + "/genre/list": { + "get": { + "summary": "Get genre list", + "description": "Retrieve list of music genres with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "genres": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Genre ID" + }, + "color": { + "type": "string", + "description": "Hex color code" + }, + "name": { + "type": "string", + "description": "Genre name" + }, + "path": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Hierarchical path of genre IDs" + }, + "slug": { + "type": "string", + "description": "URL slug" + } + } + } + } + } + } + } + }, + "example": { + "genres": { + "limit": 25, + "offset": 0, + "total": 13, + "items": [ + { + "id": 112, + "color": "#5eabc1", + "name": "Pop/Rock", + "path": [112], + "slug": "pop-rock" + } + ] + } + } + } + } + } + } + } + }, + "/label/explore": { + "get": { + "summary": "Explore labels", + "description": "Browse music labels with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 12, + "maximum": 100 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Label ID" + }, + "name": { + "type": "string", + "description": "Label name" + }, + "image": { + "type": "string", + "description": "URL to label background image" + }, + "description": { + "type": "string", + "description": "Label description" + } + } + } + } + } + } + ] + }, + "example": { + "has_more": true, + "items": [ + { + "id": 322, + "name": "Naxos", + "image": "https://static.qobuz.com/images/labels/backgrounds/naxos.jpg", + "description": null + }, + { + "id": 1630797, + "name": "Young", + "image": "https://static.qobuz.com/images/labels/backgrounds/young-8.jpg", + "description": null + } + ], + "total": 100, + "limit": 12, + "offset": 0 + } + } + } + } + } + } + }, + "/label/getList": { + "post": { + "summary": "Get labels by IDs", + "description": "Retrieve label details for multiple label IDs", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["labels_id"], + "properties": { + "labels_id": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of label IDs" + } + } + }, + "example": { + "labels_id": ["7402", "108875", "4592", "16469"] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "description": "Total number of labels returned" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Label ID" + }, + "name": { + "type": "string", + "description": "Label name" + }, + "image": { + "type": "string", + "description": "URL to label background image" + }, + "description": { + "type": "string", + "description": "Label description" + } + } + } + } + } + } + } + }, + "example": { + "labels": { + "total": 12, + "items": [ + { + "id": 7402, + "name": "WM Germany", + "image": "https://static.qobuz.com/images/labels/backgrounds/wm-germany.jpg", + "description": null + }, + { + "id": 108875, + "name": "Universal Music Division Decca Records France", + "image": null, + "description": null + } + ] + } + } + } + } + } + } + } + }, + "/label/page": { + "get": { + "summary": "Get label page", + "description": "Retrieve detailed label information including releases", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "label_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Label ID" + }, + "name": { + "type": "string", + "description": "Label name" + }, + "description": { + "type": "string", + "description": "Label description" + }, + "image": { + "type": "string", + "description": "URL to label background image" + }, + "releases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["nextReleases", "awardedReleases", "all"], + "description": "Release category ID" + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AwardAlbum" + } + } + } + } + ] + } + } + } + } + } + }, + "example": { + "id": 190680, + "name": "Spinnin' Records", + "description": null, + "image": null, + "releases": [ + { + "id": "nextReleases", + "data": { + "has_more": false, + "items": [] + } + }, + { + "id": "awardedReleases", + "data": { + "has_more": false, + "items": [] + } + }, + { + "id": "all", + "data": { + "has_more": true, + "items": [ + { + "id": "clipr8m31i43l", + "title": "Last Night On Earth", + "version": null, + "track_count": 1, + "duration": 177, + "parental_warning": false, + "image": { + "small": "https://static.qobuz.com/images/covers/3l/i4/clipr8m31i43l_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/3l/i4/clipr8m31i43l_50.jpg", + "large": "https://static.qobuz.com/images/covers/3l/i4/clipr8m31i43l_600.jpg" + }, + "artists": [ + { + "id": 2335767, + "name": "Cheat Codes", + "roles": ["main-artist"] + }, + { + "id": 1312712, + "name": "Jonita Gandhi", + "roles": ["main-artist"] + } + ], + "label": { + "id": 190680, + "name": "Spinnin' Records" + }, + "genre": { + "id": 129, + "name": "Dance", + "path": [64, 129] + }, + "dates": { + "download": "2026-03-20", + "original": "2026-03-20", + "purchase": "2026-03-20", + "stream": "2026-03-20" + }, + "awards": [], + "audio_info": { + "maximum_sampling_rate": 48, + "maximum_bit_depth": 24, + "maximum_channel_count": 2 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "hires_streamable": true, + "hires_purchasable": true + } + } + ], + "total": 100, + "limit": 50, + "offset": 0 + } + } + ] + } + } + } + } + } + } + }, + "/label/story": { + "get": { + "summary": "Get label story", + "description": "Retrieve story or editorial content for a label", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "label_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Label ID" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + }, + "example": { + "has_more": false, + "items": [], + "total": 0, + "limit": 4, + "offset": 0 + } + } + } + } + } + } + }, + "/most-popular/get": { + "get": { + "summary": "Get most popular search results", + "description": "Retrieve most popular artists, albums, and tracks matching a search query", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 30 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "most_popular": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "analytics": { + "type": "object", + "properties": { + "search_external_id": { + "type": "string" + } + } + }, + "total": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["artists", "albums", "tracks"] + }, + "content": { + "type": "object" + } + } + } + } + } + } + } + }, + "example": { + "query": "l", + "most_popular": { + "limit": 30, + "offset": 0, + "analytics": { + "search_external_id": "b5661a82f499def0d095a88f9f77a50b" + }, + "total": 1000, + "items": [ + { + "type": "artists", + "content": { + "type": "artists", + "picture": "https://static.qobuz.com/images/artists/covers/small/cccf75ebc101b321a2951112acec4f6b.jpg", + "image": { + "small": "https://static.qobuz.com/images/artists/covers/small/cccf75ebc101b321a2951112acec4f6b.jpg", + "medium": "https://static.qobuz.com/images/artists/covers/medium/cccf75ebc101b321a2951112acec4f6b.jpg", + "large": "https://static.qobuz.com/images/artists/covers/large/cccf75ebc101b321a2951112acec4f6b.jpg", + "extralarge": "https://static.qobuz.com/images/artists/covers/large/cccf75ebc101b321a2951112acec4f6b.jpg", + "mega": "https://static.qobuz.com/images/artists/covers/large/cccf75ebc101b321a2951112acec4f6b.jpg" + }, + "name": "Lady Gaga", + "slug": "lady-gaga", + "albums_count": 1061, + "id": 61585 + } + } + ] + } + } + } + } + } + } + } + }, + "/oauth2/login": { + "get": { + "summary": "User login", + "description": "Authenticate user with username and password (password must be MD5 hashed). Returns user details and OAuth2 tokens.", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "username", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "User email or login" + }, + { + "name": "password", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "MD5 hash of the user's raw password" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Authentication successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "publicId": { + "type": "string" + }, + "email": { + "type": "string" + }, + "login": { + "type": "string" + }, + "firstname": { + "type": "string", + "nullable": true + }, + "lastname": { + "type": "string", + "nullable": true + }, + "display_name": { + "type": "string" + }, + "country_code": { + "type": "string" + }, + "language_code": { + "type": "string" + }, + "zone": { + "type": "string" + }, + "store": { + "type": "string" + }, + "country": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "genre": { + "type": "string" + }, + "age": { + "type": "integer" + }, + "birthdate": { + "type": "string", + "format": "date" + }, + "creation_date": { + "type": "string", + "format": "date" + }, + "zipcode": { + "type": "string", + "nullable": true + }, + "subscription": { + "type": "object", + "properties": { + "offer": { + "type": "string" + }, + "periodicity": { + "type": "string" + }, + "start_date": { + "type": "string", + "format": "date" + }, + "end_date": { + "type": "string", + "format": "date" + }, + "is_canceled": { + "type": "boolean" + }, + "household_size_max": { + "type": "integer" + } + } + }, + "credential": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "lossy_streaming": { + "type": "boolean" + }, + "lossless_streaming": { + "type": "boolean" + }, + "hires_streaming": { + "type": "boolean" + }, + "hires_purchases_streaming": { + "type": "boolean" + }, + "mobile_streaming": { + "type": "boolean" + }, + "offline_streaming": { + "type": "boolean" + }, + "hfp_purchase": { + "type": "boolean" + }, + "included_format_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "color_scheme": { + "type": "object", + "properties": { + "logo": { + "type": "string" + } + } + }, + "label": { + "type": "string" + }, + "short_label": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + } + }, + "last_update": { + "type": "object", + "properties": { + "favorite": { + "type": "integer" + }, + "favorite_album": { + "type": "integer" + }, + "favorite_artist": { + "type": "integer" + }, + "favorite_track": { + "type": "integer" + }, + "favorite_label": { + "type": "integer" + }, + "favorite_award": { + "type": "integer" + }, + "playlist": { + "type": "integer" + }, + "purchase": { + "type": "integer" + } + } + }, + "store_features": { + "type": "object", + "properties": { + "download": { + "type": "boolean" + }, + "streaming": { + "type": "boolean" + }, + "editorial": { + "type": "boolean" + }, + "club": { + "type": "boolean" + }, + "wallet": { + "type": "boolean" + }, + "weeklyq": { + "type": "boolean" + }, + "autoplay": { + "type": "boolean" + }, + "inapp_purchase_subscripton": { + "type": "boolean" + }, + "opt_in": { + "type": "boolean" + }, + "pre_register_opt_in": { + "type": "boolean" + }, + "pre_register_zipcode": { + "type": "boolean" + }, + "music_import": { + "type": "boolean" + }, + "radio": { + "type": "boolean" + }, + "stream_purchase": { + "type": "boolean" + }, + "lyrics": { + "type": "boolean" + } + } + }, + "player_settings": { + "type": "object", + "properties": { + "sonos_audio_format": { + "type": "integer" + } + } + }, + "externals": { + "type": "object" + } + } + }, + "oauth2": { + "type": "object", + "properties": { + "token_type": { + "type": "string" + }, + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + } + } + } + } + }, + "example": { + "user": { + "id": 8029441, + "publicId": "qobuz:user:X9vX4tZykliwm", + "email": "joren@directme.in", + "login": "joren@directme.in", + "firstname": null, + "lastname": null, + "display_name": "Joren", + "country_code": "AR", + "language_code": "es", + "zone": "AR", + "store": "AR-es", + "country": "AR", + "avatar": "https://www.gravatar.com/avatar/6f693544cde79a30762dd7274ae2add6?s=50&d=mm", + "genre": "male", + "age": 22, + "birthdate": "2004-02-05", + "creation_date": "2025-10-23", + "zipcode": null, + "subscription": { + "offer": "studio", + "periodicity": "monthly", + "start_date": "2025-10-23", + "end_date": "2026-04-22", + "is_canceled": false, + "household_size_max": 1 + }, + "credential": { + "id": 3261622, + "label": "streaming-studio", + "description": "Suscriptor Qobuz Studio", + "parameters": { + "lossy_streaming": true, + "lossless_streaming": true, + "hires_streaming": true, + "hires_purchases_streaming": true, + "mobile_streaming": true, + "offline_streaming": true, + "hfp_purchase": false, + "included_format_group_ids": [1,2,3,4], + "color_scheme": { "logo": "#B8D729" }, + "label": "Qobuz Studio", + "short_label": "Studio", + "source": "subscription" + } + }, + "last_update": { + "favorite": 1774542127, + "favorite_album": 1774484091, + "favorite_artist": 1774459997, + "favorite_track": 1774542127, + "favorite_label": 1761211040, + "favorite_award": 1761211040, + "playlist": 1774388881, + "purchase": 1761211287 + }, + "store_features": { + "download": false, + "streaming": true, + "editorial": true, + "club": false, + "wallet": false, + "weeklyq": true, + "autoplay": true, + "inapp_purchase_subscripton": true, + "opt_in": true, + "pre_register_opt_in": true, + "pre_register_zipcode": false, + "music_import": true, + "radio": true, + "stream_purchase": true, + "lyrics": true + }, + "player_settings": { "sonos_audio_format": 7 }, + "externals": {} + }, + "oauth2": { + "token_type": "bearer", + "access_token": "8MCZhiPj5sARMaiPddypQYJw6ga55ZoF", + "refresh_token": "0B1Dt2BXl1L2XBxTDhxLUou3iBFkv57V", + "expires_in": 1382400 + } + } + } + } + } + } + } + }, + "/oauth2/token": { + "post": { + "summary": "Refresh OAuth2 token", + "description": "Exchange a refresh token for a new access token using client credentials", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "grant_type": { + "type": "string", + "enum": ["refresh_token"] + } + }, + "required": ["client_id", "client_secret", "refresh_token", "grant_type"] + }, + "example": { + "client_id": "312369995", + "client_secret": "828193befd12dc579e9cc1d402d3be1e", + "refresh_token": "k9ZRLBs8DiqBy2KCENd9K9zujfSmcCWW", + "grant_type": "refresh_token" + } + } + } + }, + "responses": { + "200": { + "description": "Token refresh successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "token_type": { + "type": "string" + } + } + }, + "example": { + "access_token": "sQW4A0Eg4XXripGbbXgVsZMhICodAr2z", + "refresh_token": "FRBKKYury9psGm8WxfEmtLL6CISs3Bd7", + "expires_in": 1382400, + "token_type": "bearer" + } + } + } + } + } + } + }, + "/playlist/addTracks": { + "post": { + "summary": "Add tracks to playlist", + "description": "Add one or more tracks to a playlist. If no_duplicate is true, duplicates will be rejected with 409 error.", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "playlist_id": { + "type": "integer" + }, + "track_ids": { + "type": "string", + "description": "Comma-separated list of track IDs" + }, + "no_duplicate": { + "type": "boolean", + "default": true + } + }, + "required": ["playlist_id", "track_ids"] + }, + "example": { + "playlist_id": 47576025, + "track_ids": "35883373", + "no_duplicate": true + } + } + } + }, + "responses": { + "200": { + "description": "Tracks added successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Playlist" + }, + "example": { + "id": 47576025, + "name": "assorted_genres.bin", + "description": "This has ended up being a playlist for songs that don't fit in other playlists", + "tracks_count": 727, + "users_count": 0, + "duration": 146293, + "public_at": 1764889200, + "created_at": 1764937232, + "updated_at": 1774548274, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 8029441, + "name": "Joren" + } + } + } + } + }, + "409": { + "description": "Duplicate tracks found and no_duplicate is true", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "playlist_id": { + "type": "integer" + }, + "duplicate_track_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "status": "error", + "code": 409, + "message": "Duplicate track(s) found - no track(s) added to playlist", + "data": { + "playlist_id": 47576025, + "duplicate_track_ids": ["35883373"] + } + } + } + } + } + } + } + }, + "/playlist/create": { + "post": { + "summary": "Create a new playlist", + "description": "Create a playlist with optional initial tracks and visibility settings", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Playlist name" + }, + "description": { + "type": "string", + "default": "" + }, + "track_ids": { + "type": "string", + "description": "Optional comma-separated list of track IDs to add initially" + }, + "is_public": { + "type": "boolean", + "default": false + }, + "is_collaborative": { + "type": "boolean", + "default": false + } + }, + "required": ["name"] + }, + "example": { + "name": "New playlist [9]", + "description": "", + "track_ids": "", + "is_public": false, + "is_collaborative": false + } + } + } + }, + "responses": { + "200": { + "description": "Playlist created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Playlist" + }, + "example": { + "id": 61150456, + "name": "New playlist [9]", + "description": "", + "tracks_count": 0, + "users_count": 0, + "duration": 0, + "public_at": false, + "created_at": 1774548375, + "updated_at": 1774548375, + "is_public": false, + "is_collaborative": false, + "owner": { + "id": 8029441, + "name": "Joren" + } + } + } + } + } + } + } + }, + "/playlist/delete": { + "get": { + "summary": "Delete a playlist", + "description": "Delete a playlist by ID", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "playlist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Playlist ID to delete" + } + ], + "responses": { + "200": { + "description": "Playlist deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + }, + "example": { + "status": "success" + } + } + } + } + } + } + }, + "/playlist/deleteTracks": { + "post": { + "summary": "Delete tracks from playlist", + "description": "Remove specific tracks from a playlist by playlist_track_ids (internal playlist-specific track IDs)", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "playlist_id": { + "type": "integer" + }, + "playlist_track_ids": { + "type": "string", + "description": "Comma-separated list of playlist-specific track IDs" + } + }, + "required": ["playlist_id", "playlist_track_ids"] + }, + "example": { + "playlist_id": 61150456, + "playlist_track_ids": "11141882120" + } + } + } + }, + "responses": { + "200": { + "description": "Tracks deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tracks_count": { + "type": "integer" + }, + "users_count": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "public_at": { + "oneOf": [ + { "type": "boolean" }, + { "type": "integer" } + ] + }, + "created_at": { + "type": "integer" + }, + "updated_at": { + "type": "integer" + }, + "is_public": { + "type": "boolean" + }, + "is_collaborative": { + "type": "boolean" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + }, + "example": { + "status": "success", + "id": 61150456, + "name": "New playlist [9]", + "description": "", + "tracks_count": 0, + "users_count": 0, + "duration": 0, + "public_at": 1774548380, + "created_at": 1774548375, + "updated_at": 1774548398, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 8029441, + "name": "Joren" + } + } + } + } + } + } + } + }, + "/playlist/get": { + "get": { + "summary": "Get playlist details", + "description": "Retrieve playlist metadata and optionally include tracks or track IDs", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "playlist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Playlist ID" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 350 + }, + "description": "Maximum number of tracks to return (when extra=tracks)" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for track pagination (when extra=tracks)" + }, + { + "name": "extra", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["tracks", "track_ids"] + }, + "description": "Include additional data: 'tracks' for full track objects, 'track_ids' for IDs only" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { "$ref": "#/components/schemas/Playlist" }, + { + "type": "object", + "properties": { + "tracks": { + "$ref": "#/components/schemas/PaginatedResponse" + }, + "track_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "example": { + "id": 47576025, + "name": "assorted_genres.bin", + "description": "This has ended up being a playlist for songs that don't fit in other playlists", + "tracks_count": 727, + "users_count": 0, + "duration": 146293, + "public_at": 1764889200, + "created_at": 1764937232, + "updated_at": 1774548274, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 8029441, + "name": "Joren" + }, + "slug": "just-somewhat-similar-genre-songs-dd", + "genres": [], + "images": [ + "https://static.qobuz.com/images/covers/92/62/0072064246292_50.jpg", + "https://static.qobuz.com/images/covers/fc/8u/zct90v1sc8ufc_50.jpg", + "https://static.qobuz.com/images/covers/lc/ct/g5dklnsixctlc_50.jpg", + "https://static.qobuz.com/images/covers/21/97/0828765549721_50.jpg" + ], + "is_featured": false, + "published_from": null, + "published_to": null, + "images150": [ + "https://static.qobuz.com/images/covers/92/62/0072064246292_150.jpg", + "https://static.qobuz.com/images/covers/fc/8u/zct90v1sc8ufc_150.jpg", + "https://static.qobuz.com/images/covers/lc/ct/g5dklnsixctlc_150.jpg", + "https://static.qobuz.com/images/covers/21/97/0828765549721_150.jpg" + ], + "images300": [ + "https://static.qobuz.com/images/covers/92/62/0072064246292_300.jpg", + "https://static.qobuz.com/images/covers/fc/8u/zct90v1sc8ufc_300.jpg", + "https://static.qobuz.com/images/covers/lc/ct/g5dklnsixctlc_300.jpg", + "https://static.qobuz.com/images/covers/21/97/0828765549721_300.jpg" + ] + } + } + } + } + } + } + }, + "/playlist/getFeatured": { + "get": { + "summary": "Get featured playlists", + "description": "Retrieve curated featured playlists by type and genre", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["last-created", "most-popular", "editorial"] + }, + "description": "Type of featured playlists" + }, + { + "name": "genre_ids", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated genre IDs to filter by" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 6 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "playlists": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + }, + "example": { + "playlists": { + "offset": 0, + "limit": 6, + "total": 1429, + "items": [ + { + "owner": { + "name": "Qobuz Latinoamérica", + "id": 1752421 + }, + "image_rectangle_mini": ["https://static.qobuz.com/images/playlists/7801943_cb084b9bc2b9e578e26ed41384465fe7_rectangle_mini.jpg"], + "users_count": 461, + "images150": ["https://static.qobuz.com/images/covers/98/g5/e644dwd1dg598_150.jpg"], + "images": ["https://static.qobuz.com/images/covers/98/g5/e644dwd1dg598_50.jpg"], + "featured_artists": [], + "is_collaborative": false, + "stores": ["AR-es", "CL-es", "CO-es", "MX-es"], + "description": "Con Muse, Foo Fighters, Peter Frampton, The Black Keys...", + "created_at": 1639742591, + "images300": ["https://static.qobuz.com/images/covers/98/g5/e644dwd1dg598_300.jpg"], + "tags": [ + { + "is_discover": true, + "featured_tag_id": "19", + "name_json": "{\"fr\":\"Nouveautés\",\"en\":\"New Releases\",\"de\":\"Neuheiten\",\"it\":\"Novità\",\"nl\":\"New releases\",\"es\":\"Novedades\",\"pt\":\"Novidades\",\"ja\":\"ニューリリース\"}", + "slug": "new", + "genre_tag": null, + "color": "" + } + ], + "duration": 17779, + "updated_at": 1774542959, + "genres": [ + { + "path": [112], + "name": "Pop/Rock", + "id": 112, + "slug": "pop-rock", + "color": "#0070ef", + "percent": 50 + } + ], + "image_rectangle": ["https://static.qobuz.com/images/playlists/7801943_cb084b9bc2b9e578e26ed41384465fe7_rectangle.jpg"], + "tracks_count": 80, + "public_at": 1639695600, + "name": "Lo último en Rock", + "is_public": true, + "id": 7801943, + "slug": "la-seleccion-rock", + "is_featured": true + } + ] + } + } + } + } + } + } + } + }, + "/playlist/getUserPlaylistIds": { + "get": { + "summary": "Get user's playlist IDs", + "description": "Retrieve list of playlist IDs owned or subscribed by the authenticated user", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "playlists": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "example": { + "playlists": [ + 12435784, + 18423515, + 18424267, + 18432337, + 18477367, + 18479109, + 31294453, + 42090404, + 43929926, + 47575596, + 47575665, + 47575711, + 47575765, + 47576025, + 47576101, + 60727909, + 61150571 + ] + } + } + } + } + } + } + }, + "/playlist/getUserPlaylists": { + "get": { + "summary": "Get user's playlists", + "description": "Retrieve detailed playlists owned or subscribed by the authenticated user", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["owner", "subscriber", "owner,subscriber"] + }, + "description": "Filter by ownership/subscription" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 350 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + } + }, + "playlists": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + }, + "example": { + "user": { + "id": 8029441, + "login": "joren@directme.in" + }, + "playlists": { + "offset": 0, + "limit": 350, + "total": 16, + "items": [ + { + "id": 18479109, + "name": "italian cosmic disco classics [ rare & forgotten synth-heavy dance compilation • 70s & early 80s ]", + "description": "// cosmic disco describes various forms of synthesizer - heavy afro influenced - dance music that were originally developed and promoted by a small number of djs in certain discothèques of northern italy / decade: late '70s • early '80s / style: post-ita", + "tracks_count": 81, + "users_count": 1, + "duration": 29620, + "public_at": 1702249200, + "created_at": 1702291884, + "updated_at": 1774548305, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 2671414, + "name": "Theodore Kapa" + }, + "indexed_at": 1774548306, + "slug": "italian-cosmic-disco-classics-rare-forgotten-synth-heavy-dance-compilation-70s-early-80s", + "genres": [], + "images": [ + "https://static.qobuz.com/images/covers/06/73/3700593707306_50.jpg", + "https://static.qobuz.com/images/covers/25/02/5060281610225_50.jpg", + "https://static.qobuz.com/images/covers/04/06/0060255710604_50.jpg", + "https://static.qobuz.com/images/covers/za/ju/m0fe5nqy8juza_50.jpg" + ], + "is_published": false, + "is_featured": false, + "published_from": null, + "published_to": null, + "images150": [ + "https://static.qobuz.com/images/covers/06/73/3700593707306_150.jpg", + "https://static.qobuz.com/images/covers/25/02/5060281610225_150.jpg", + "https://static.qobuz.com/images/covers/04/06/0060255710604_150.jpg", + "https://static.qobuz.com/images/covers/za/ju/m0fe5nqy8juza_150.jpg" + ], + "images300": [ + "https://static.qobuz.com/images/covers/06/73/3700593707306_300.jpg", + "https://static.qobuz.com/images/covers/25/02/5060281610225_300.jpg", + "https://static.qobuz.com/images/covers/04/06/0060255710604_300.jpg", + "https://static.qobuz.com/images/covers/za/ju/m0fe5nqy8juza_300.jpg" + ], + "position": 0, + "subscribed_at": 1774548305 + } + ] + } + } + } + } + } + } + } + }, + "/playlist/search": { + "get": { + "summary": "Search playlists", + "description": "Search public playlists by query", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 8 + }, + "description": "Maximum number of results" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "playlists": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + }, + "example": { + "query": "lost frequencies", + "playlists": { + "limit": 8, + "offset": 0, + "analytics": { + "search_external_id": "1579d3e5c4e0c6537de128b4645d5a21" + }, + "total": 15, + "items": [ + { + "id": 20123657, + "name": "Lost Frequencies (The lost Anthems of Tomorrowland 2024) - The golden Decade Edition", + "description": "The official Road to Tomorrowland 2024 Playlist with DJ's like Hardwell, Alok, Afrojack, W&W, Armin van Buuren, The Chainsmokers, Martin Garrix, Dimitri Vegas & Like Mike, Steve Aoki, Don Diablo, Alan Walker, Lost Frequencies, Zedd and many more ...", + "tracks_count": 959, + "users_count": 1, + "duration": 231651, + "public_at": 1709852400, + "created_at": 1709906752, + "updated_at": 1767985405, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 2958507, + "name": "Maximiliano Sanchez" + }, + "indexed_at": 1767985406, + "slug": "lost-frequencies-the-lost-anthems-of-tomorrowland-2024-the-golden-decade-edition", + "genres": [], + "images": [ + "https://static.qobuz.com/images/covers/kb/g0/f4tp7vu5ag0kb_50.jpg", + "https://static.qobuz.com/images/covers/5b/yz/uryux54z3yz5b_50.jpg", + "https://static.qobuz.com/images/covers/11/80/0886443418011_50.jpg", + "https://static.qobuz.com/images/covers/pb/n2/j840ltdidn2pb_50.jpg" + ], + "is_published": false, + "is_featured": false, + "published_from": null, + "published_to": null, + "images150": [ + "https://static.qobuz.com/images/covers/kb/g0/f4tp7vu5ag0kb_150.jpg", + "https://static.qobuz.com/images/covers/5b/yz/uryux54z3yz5b_150.jpg", + "https://static.qobuz.com/images/covers/11/80/0886443418011_150.jpg", + "https://static.qobuz.com/images/covers/pb/n2/j840ltdidn2pb_150.jpg" + ], + "images300": [ + "https://static.qobuz.com/images/covers/kb/g0/f4tp7vu5ag0kb_300.jpg", + "https://static.qobuz.com/images/covers/5b/yz/uryux54z3yz5b_300.jpg", + "https://static.qobuz.com/images/covers/11/80/0886443418011_300.jpg", + "https://static.qobuz.com/images/covers/pb/n2/j840ltdidn2pb_300.jpg" + ] + } + ] + } + } + } + } + } + } + } + }, + "/playlist/story": { + "get": { + "summary": "Get playlist story", + "description": "Retrieve story or editorial content for a playlist", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "playlist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Playlist ID" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + }, + "description": "Maximum number of results" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + }, + "example": { + "has_more": false, + "items": [], + "total": 0, + "limit": 4, + "offset": 0 + } + } + } + } + } + } + }, + "/playlist/subscribe": { + "get": { + "summary": "Subscribe to playlist", + "description": "Subscribe to a public playlist to add it to user's library", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "playlist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Playlist ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful subscription", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success"] + } + }, + "required": ["status"] + }, + "example": { + "status": "success" + } + } + } + } + } + } + }, + "/playlist/unsubscribe": { + "get": { + "summary": "Unsubscribe from playlist", + "description": "Remove playlist subscription from user's library", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "playlist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Playlist ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful unsubscription", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success"] + } + }, + "required": ["status"] + }, + "example": { + "status": "success" + } + } + } + } + } + } + }, + "/playlist/update": { + "post": { + "summary": "Update playlist metadata", + "description": "Modify playlist title, description, visibility, and collaboration settings", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "playlist_id": { + "type": "integer", + "description": "Playlist ID" + }, + "name": { + "type": "string", + "description": "Playlist name" + }, + "description": { + "type": "string", + "description": "Playlist description (optional)" + }, + "is_public": { + "type": "boolean", + "description": "Whether playlist is publicly visible" + }, + "is_collaborative": { + "type": "boolean", + "description": "Whether playlist allows collaborative editing" + } + }, + "required": ["playlist_id", "name", "is_public", "is_collaborative"] + }, + "example": { + "playlist_id": 61150456, + "name": "New playlist [9]", + "description": "", + "is_public": true, + "is_collaborative": false + } + } + } + }, + "responses": { + "200": { + "description": "Playlist updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Playlist" + }, + "example": { + "id": 61150456, + "name": "New playlist [9]", + "description": "", + "tracks_count": 0, + "users_count": 0, + "duration": 0, + "public_at": 1774548380, + "created_at": 1774548375, + "updated_at": 1774548380, + "is_public": true, + "is_collaborative": false, + "owner": { + "id": 8029441, + "name": "Joren" + } + } + } + } + } + } + } + }, + "/qws/createToken": { + "post": { + "summary": "Create QWS token", + "description": "Generate a JWT token for Qobuz WebSocket (QWS) real-time streaming", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "jwt": { + "type": "string", + "enum": ["jwt_qws"], + "description": "Must be 'jwt_qws'" + } + }, + "required": ["jwt"] + }, + "example": { + "jwt": "jwt_qws" + } + } + } + }, + "responses": { + "200": { + "description": "QWS token created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jwt_qws": { + "type": "object", + "properties": { + "exp": { + "type": "integer", + "description": "Token expiration timestamp" + }, + "jwt": { + "type": "string", + "description": "JWT token for WebSocket authentication" + }, + "endpoint": { + "type": "string", + "description": "WebSocket endpoint URL" + } + }, + "required": ["exp", "jwt", "endpoint"] + } + }, + "required": ["jwt_qws"] + }, + "example": { + "jwt_qws": { + "exp": 1774551781, + "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NzQ1NDgxODEsImV4cCI6MTc3NDU1MTc4MSwiaXNzIjoiUW9idXogQVBJIiwicXVpZCI6ODAyOTQ0MSwicWFpZCI6IjMxMjM2OTk5NSJ9.iponghxT2cRFWvFvT9t91QVpQjh4ZEA19flU4lNHMuM", + "endpoint": "wss://qws-us-prod.qobuz.com/ws" + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": ["error"] + } + }, + "required": ["message", "code", "status"] + }, + "example": { + "message": "User authentication is required. (Root=1-69c57907-7b414aff302c26850b7a3780)", + "code": 401, + "status": "error" + } + } + } + } + } + } + }, + "/radio/artist": { + "get": { + "summary": "Generate artist radio", + "description": "Create a radio station based on an artist's musical style", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "artist_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Artist ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Radio station generated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "enum": ["radio-artist"], + "description": "Radio algorithm type" + }, + "type": { + "type": "string", + "enum": ["radio-artist"], + "description": "Radio type" + }, + "title": { + "type": "string", + "description": "Radio station title (artist name)" + }, + "images": { + "type": "object", + "properties": { + "small": { + "type": "string", + "format": "uri", + "description": "Small cover image URL" + }, + "large": { + "type": "string", + "format": "uri", + "description": "Large cover image URL" + } + }, + "required": ["small", "large"] + }, + "duration": { + "type": "integer", + "description": "Total duration of all tracks in seconds" + }, + "track_count": { + "type": "integer", + "description": "Number of tracks in radio station" + }, + "tracks": { + "$ref": "#/components/schemas/PaginatedResponse" + } + }, + "required": ["algorithm", "type", "title", "images", "duration", "track_count", "tracks"] + }, + "example": { + "algorithm": "radio-artist", + "type": "radio-artist", + "title": "Lost Frequencies", + "images": { + "small": "https://static.qobuz.com/images/artists/covers/small/77817762dd3688191f752509901e4338.jpg", + "large": "https://static.qobuz.com/images/artists/covers/large/77817762dd3688191f752509901e4338.jpg" + }, + "duration": 5410, + "track_count": 30, + "tracks": { + "limit": 30, + "items": [ + { + "id": 47299759, + "title": "You Don't Have To Like It", + "version": null, + "work": null, + "isrc": "NLZ541800093", + "duration": 177, + "parental_warning": false, + "physical_support": { + "media_number": 1, + "track_number": 1 + }, + "audio_info": { + "maximum_bit_depth": 16, + "maximum_channel_count": 2, + "maximum_sampling_rate": 44.1 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "sampleable": true, + "hires_streamable": false, + "hires_purchasable": false + }, + "artists": [ + { + "id": 955835, + "name": "Lucas & Steve", + "roles": ["main-artist"] + } + ], + "composer": { + "id": 3251389, + "name": "Alon Dreesde" + }, + "album": { + "id": "iom5qw9m24gbb", + "title": "You Don't Have To Like It", + "version": null, + "image": { + "small": "https://static.qobuz.com/images/covers/bb/4g/iom5qw9m24gbb_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/bb/4g/iom5qw9m24gbb_50.jpg", + "large": "https://static.qobuz.com/images/covers/bb/4g/iom5qw9m24gbb_600.jpg" + }, + "label": { + "id": 190680, + "name": "Spinnin' Records" + }, + "genre": { + "id": 129, + "name": "Dance", + "path": [64, 129] + } + }, + "copyright": "© 2018 SpinninRecords.com ℗ 2018 SpinninRecords.com" + } + ] + } + } + } + } + } + } + } + }, + "/radio/track": { + "get": { + "summary": "Generate track radio", + "description": "Create a radio station based on a specific track's musical style", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "track_id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Track ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Radio station generated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "enum": ["radio-track"], + "description": "Radio algorithm type" + }, + "type": { + "type": "string", + "enum": ["radio-track"], + "description": "Radio type" + }, + "title": { + "type": "string", + "description": "Radio station title (track name)" + }, + "images": { + "type": "object", + "properties": { + "small": { + "type": "string", + "format": "uri", + "description": "Small cover image URL" + }, + "large": { + "type": "string", + "format": "uri", + "description": "Large cover image URL" + } + }, + "required": ["small", "large"] + }, + "duration": { + "type": "integer", + "description": "Total duration of all tracks in seconds" + }, + "track_count": { + "type": "integer", + "description": "Number of tracks in radio station" + }, + "tracks": { + "$ref": "#/components/schemas/PaginatedResponse" + } + }, + "required": ["algorithm", "type", "title", "images", "duration", "track_count", "tracks"] + }, + "example": { + "algorithm": "radio-track", + "type": "radio-track", + "title": "Big in Japan ", + "images": { + "small": "https://static.qobuz.com/images/covers/db/31/byz76ojp231db_230.jpg", + "large": "https://static.qobuz.com/images/covers/db/31/byz76ojp231db_600.jpg" + }, + "duration": 7586, + "track_count": 29, + "tracks": { + "limit": 29, + "items": [ + { + "id": 57635339, + "title": "Big in Japan ", + "version": "2019 Remaster", + "work": null, + "isrc": "DEA621800673", + "duration": 284, + "parental_warning": false, + "physical_support": { + "media_number": 1, + "track_number": 3 + }, + "audio_info": { + "maximum_bit_depth": 24, + "maximum_channel_count": 2, + "maximum_sampling_rate": 44.1 + }, + "rights": { + "purchasable": false, + "streamable": true, + "downloadable": false, + "sampleable": true, + "hires_streamable": true, + "hires_purchasable": true + }, + "artists": [ + { + "id": 372772, + "name": "Alphaville", + "roles": ["main-artist"] + } + ], + "composer": null, + "album": { + "id": "byz76ojp231db", + "title": "Forever Young ", + "version": "Super Deluxe Edition; 2019 Remaster", + "image": { + "small": "https://static.qobuz.com/images/covers/db/31/byz76ojp231db_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/db/31/byz76ojp231db_50.jpg", + "large": "https://static.qobuz.com/images/covers/db/31/byz76ojp231db_600.jpg" + }, + "label": { + "id": 7402, + "name": "WM Germany" + }, + "genre": { + "id": 117, + "name": "Pop", + "path": [112, 117] + } + }, + "copyright": "© 1984, 2019 WEA / Warner Music Group Germany Holding GmbH / A Warner Music Group Company ℗ 2019, 1984 WEA / Warner Music Group Germany Holding GmbH / A Warner Music Group Company" + } + ] + } + } + } + } + } + } + } + }, + "/search/report": { + "post": { + "summary": "Report search interactions", + "description": "Send search analytics events for tracking user interactions", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "Event schema version", + "example": "01.00" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "integer", + "description": "Event timestamp in milliseconds" + }, + "search": { + "type": "string", + "description": "Search query text" + }, + "type": { + "type": "string", + "enum": ["artist", "album", "track", "playlist", "label"], + "description": "Type of search result clicked" + }, + "position": { + "type": "string", + "description": "Position of result in search results (1-indexed)" + }, + "id_client": { + "type": "integer", + "description": "User ID" + }, + "queryid": { + "type": "string", + "description": "Unique query identifier" + }, + "objectid": { + "type": "string", + "description": "ID of clicked object (artist ID, album ID, etc.)" + } + }, + "required": ["date", "search", "type", "position", "id_client", "queryid", "objectid"] + } + } + }, + "required": ["version", "events"] + }, + "example": { + "version": "01.00", + "events": [ + { + "date": 1774548475714, + "search": "lost frequencies", + "type": "artist", + "position": "1", + "id_client": 8029441, + "queryid": "ddc1f5f1871c38feabd9ac82416c1c6f", + "objectid": "1863897" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Search event reported successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success"] + } + }, + "required": ["status"] + }, + "example": { + "status": "success" + } + } + } + } + } + } + }, + "/session/start": { + "post": { + "summary": "Start a playback session", + "description": "Initialize a streaming session for audio playback", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "profile": { + "type": "string", + "enum": ["qbz-1"], + "description": "Playback profile identifier" + } + }, + "required": ["profile"] + }, + "example": { + "profile": "qbz-1" + } + } + } + }, + "responses": { + "200": { + "description": "Session started successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Unique session identifier" + }, + "profile": { + "type": "string", + "description": "Playback profile used" + }, + "infos": { + "type": "string", + "description": "Base64-encoded session information" + }, + "expires_at": { + "type": "integer", + "description": "Session expiration timestamp" + } + }, + "required": ["session_id", "profile", "infos", "expires_at"] + }, + "example": { + "session_id": "qmG7v51zldj5lYJbsAbPpA", + "profile": "qbz-1", + "infos": "aG0ZxY81ApaRIQLxRuguEw.bm9uZQ", + "expires_at": 1774551783 + } + } + } + } + } + } + }, + "/story/search": { + "get": { + "summary": "Search stories", + "description": "Search for editorial stories by query with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 8, + "maximum": 50 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The original search query" + }, + "stories": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "analytics": { + "type": "object", + "properties": { + "search_external_id": { + "type": "string" + } + } + }, + "facets": { + "type": "object", + "nullable": true + } + } + } + ] + } + } + }, + "example": { + "query": "lost frequencies", + "stories": { + "limit": 8, + "offset": 0, + "analytics": { + "search_external_id": "6504486f17c71b4cdbaf55961d1bebc2" + }, + "total": 0, + "items": [], + "facets": null + } + } + } + } + } + } + } + }, + "/track/get": { + "get": { + "summary": "Get track details", + "description": "Retrieve detailed information about a specific track", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "track_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Track ID" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Track" + }, + "example": { + "maximum_bit_depth": 24, + "copyright": "(P) 2026 Universal Music Division Decca Records France", + "performers": "Flore Benguigui & The Sensible Notes, MainArtist", + "audio_info": { + "replaygain_track_peak": 0.977264, + "replaygain_track_gain": -8.68 + }, + "performer": { + "name": "Flore Benguigui & The Sensible Notes", + "id": 28885011 + }, + "album": { + "maximum_bit_depth": 24, + "image": { + "small": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_50.jpg", + "large": "https://static.qobuz.com/images/covers/8q/wt/xxc24b1r0wt8q_600.jpg" + }, + "media_count": 1, + "artist": { + "image": null, + "name": "Flore Benguigui & The Sensible Notes", + "id": 28885011, + "albums_count": 6, + "slug": "flore-benguigui--the-sensible-notes", + "picture": null + }, + "upc": "0602488154758", + "released_at": 1773356400, + "label": { + "name": "Universal Music Division Decca Records France", + "id": 108875, + "albums_count": 3607, + "supplier_id": 1, + "slug": "universal-music-division-decca-records-france" + }, + "title": "i-330", + "qobuz_id": 378123654, + "version": null, + "duration": 2764, + "parental_warning": false, + "tracks_count": 12, + "genre": { + "path": [80, 89], + "color": "#0070ef", + "name": "Jazz vocal", + "id": 89, + "slug": "jazz-vocal" + }, + "maximum_channel_count": 2, + "id": "xxc24b1r0wt8q", + "maximum_sampling_rate": 48, + "previewable": true, + "sampleable": true, + "displayable": true, + "streamable": true, + "streamable_at": 1774494000, + "downloadable": false, + "purchasable_at": null, + "purchasable": false, + "release_date_original": "2026-03-13", + "release_date_download": "2026-03-13", + "release_date_stream": "2026-03-13", + "hires": true, + "hires_streamable": true, + "awards": [ + { + "name": "Qobuzissime", + "slug": "qobuz", + "award_slug": "qobuzissime", + "awarded_at": 1773961200, + "award_id": "88", + "publication_id": "2", + "publication_name": "Qobuz", + "publication_slug": "qobuz" + } + ] + }, + "work": null, + "composer": { + "name": "Flore Benguigui", + "id": 28885012 + }, + "isrc": "FR6V82600001", + "title": "i-330", + "version": null, + "duration": 2764, + "parental_warning": false, + "track_number": 1, + "maximum_channel_count": 2, + "id": 378123655, + "media_number": 1, + "maximum_sampling_rate": 48, + "release_date_original": "2026-03-13", + "release_date_download": "2026-03-13", + "release_date_stream": "2026-03-13", + "purchasable": false, + "streamable": true, + "previewable": true, + "sampleable": true, + "downloadable": false, + "displayable": true, + "purchasable_at": null, + "streamable_at": 1774494000, + "hires": true, + "hires_streamable": true + } + } + } + } + } + } + }, + "/track/search": { + "get": { + "summary": "Search tracks", + "description": "Search for tracks by query with pagination", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "name": "query", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search query" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0 + }, + "description": "Offset for pagination" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 8, + "maximum": 50 + }, + "description": "Maximum number of results" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The original search query" + }, + "tracks": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "analytics": { + "type": "object", + "properties": { + "search_external_id": { + "type": "string" + } + } + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Track" + } + } + } + } + ] + } + } + }, + "example": { + "query": "lost frequencies", + "tracks": { + "limit": 8, + "offset": 0, + "analytics": { + "search_external_id": "40c98535a47ce6cc224d05e0fd5427c2" + }, + "total": 1000, + "items": [ + { + "maximum_bit_depth": 24, + "copyright": "(P) 2021 Lost & Cie Music SPRL exclusively licensed to Epic Amsterdam, with courtesy of Sony Music Entertainment Belgium NV/SA", + "performers": "Sebastian Arman, Composer, Lyricist - Michael Patrick Kelly, Composer, Lyricist - Joacim Bo Persson, Composer, Lyricist - Lorna Blackwood, Vocal Producer - Lost Frequencies, Producer, MainArtist, AssociatedPerformer - Felix de Laet, Composer, Lyricist - Dag Lundberg, Composer, Lyricist - Calum Scott, MainArtist, AssociatedPerformer, Vocal - Lost Frequencies, Calum Scott, AssociatedPerformer", + "audio_info": { + "replaygain_track_peak": 0.977264, + "replaygain_track_gain": -8.68 + }, + "performer": { + "name": "Lost Frequencies", + "id": 1863897 + }, + "album": { + "maximum_bit_depth": 24, + "image": { + "small": "https://static.qobuz.com/images/covers/jb/jb/qofj8ace3jbjb_230.jpg", + "thumbnail": "https://static.qobuz.com/images/covers/jb/jb/qofj8ace3jbjb_50.jpg", + "large": "https://static.qobuz.com/images/covers/jb/jb/qofj8ace3jbjb_600.jpg" + }, + "media_count": 1, + "artist": { + "image": null, + "name": "Lost Frequencies", + "id": 1863897, + "albums_count": 990, + "slug": "lost-frequencies", + "picture": null + }, + "upc": "0886449449668", + "released_at": 1627596000, + "label": { + "name": "Epic Amsterdam", + "id": 85930, + "albums_count": 405, + "supplier_id": 23, + "slug": "epic-amsterdam" + }, + "title": "Where Are You Now", + "qobuz_id": 127415449, + "version": null, + "duration": 148, + "parental_warning": false, + "tracks_count": 1, + "genre": { + "path": [64, 68], + "name": "House", + "id": 68, + "slug": "house" + }, + "maximum_channel_count": 2, + "id": "qofj8ace3jbjb", + "maximum_sampling_rate": 44.1, + "previewable": true, + "sampleable": true, + "displayable": true, + "streamable": true, + "streamable_at": 1750129200, + "downloadable": false, + "purchasable_at": null, + "purchasable": false, + "release_date_original": "2021-07-30", + "release_date_download": "2021-07-30", + "release_date_stream": "2021-07-30", + "release_date_purchase": "2021-07-30", + "hires": true, + "hires_streamable": true + }, + "work": null, + "composer": { + "name": "Felix de Laet", + "id": 1964137 + }, + "isrc": "BEHP42100067", + "title": "Where Are You Now", + "version": null, + "duration": 148, + "parental_warning": false, + "track_number": 1, + "maximum_channel_count": 2, + "id": 127415450, + "media_number": 1, + "maximum_sampling_rate": 44.1, + "release_date_original": "2021-07-30", + "release_date_download": "2021-07-30", + "release_date_stream": "2021-07-30", + "release_date_purchase": "2021-07-30", + "purchasable": false, + "streamable": true, + "previewable": true, + "sampleable": true, + "downloadable": false, + "displayable": true, + "purchasable_at": null, + "streamable_at": 1750129200, + "hires": true, + "hires_streamable": true + } + ] + } + } + } + } + } + } + } + }, + "/user/get": { + "get": { + "summary": "Get user profile", + "description": "Retrieve current user's profile information", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + }, + "example": { + "id": 8029441, + "publicId": "qobuz:user:X9vX4tZykliwm", + "email": "joren@directme.in", + "login": "joren@directme.in", + "firstname": null, + "lastname": null, + "display_name": "Joren", + "country_code": "AR", + "language_code": "es", + "zone": "AR", + "store": "AR-es", + "country": "AR", + "avatar": "https://www.gravatar.com/avatar/6f693544cde79a30762dd7274ae2add6?s=50&d=mm", + "genre": "male", + "age": 22, + "birthdate": "2004-02-05", + "creation_date": "2025-10-23", + "zipcode": null, + "subscription": { + "offer": "studio", + "periodicity": "monthly", + "start_date": "2025-10-23", + "end_date": "2026-04-22", + "is_canceled": false, + "household_size_max": 1 + }, + "credential": { + "id": 3261622, + "label": "streaming-studio", + "description": "Suscriptor Qobuz Studio", + "parameters": { + "lossy_streaming": true, + "lossless_streaming": true, + "hires_streaming": true, + "hires_purchases_streaming": true, + "mobile_streaming": true, + "offline_streaming": true, + "hfp_purchase": false, + "included_format_group_ids": [1, 2, 3, 4], + "color_scheme": { + "logo": "#B8D729" + }, + "label": "Qobuz Studio", + "short_label": "Studio", + "source": "subscription" + } + }, + "last_update": { + "favorite": 1774542127, + "favorite_album": 1774484091, + "favorite_artist": 1774459997, + "favorite_track": 1774542127, + "favorite_label": 1761211040, + "favorite_award": 1761211040, + "playlist": 1774388881, + "purchase": 1761211287 + }, + "store_features": { + "download": false, + "streaming": true, + "editorial": true, + "club": false, + "wallet": false, + "weeklyq": true, + "autoplay": true, + "inapp_purchase_subscripton": true, + "opt_in": true, + "pre_register_opt_in": true, + "pre_register_zipcode": false, + "music_import": true, + "radio": true, + "stream_purchase": true, + "lyrics": true + } + } + } + } + } + } + } + }, + "/user/plan": { + "get": { + "summary": "Get user subscription plans", + "description": "Retrieve current user's subscription plan details", + "security": [ + { + "UserToken": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/PaginatedResponse" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": ["qobuz", "partner"] + }, + "end_date": { + "type": "string", + "format": "date" + }, + "price": { + "type": "number" + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "offer": { + "type": "string", + "enum": ["studio", "sublime", "premier", "family"] + }, + "formula": { + "type": "string", + "enum": ["student", "regular", "family"] + }, + "periodicity": { + "type": "string", + "enum": ["monthly", "annual", "quarterly"] + }, + "trial": { + "type": "boolean" + }, + "household_size_max": { + "type": "integer" + }, + "household_owner": { + "type": "string", + "nullable": true + } + } + } + } + } + } + ] + }, + "example": { + "has_more": false, + "items": [ + { + "source": "qobuz", + "end_date": "2026-04-22", + "price": 1.99, + "currency": "USD", + "offer": "studio", + "formula": "student", + "periodicity": "monthly", + "trial": false, + "household_size_max": 1, + "household_owner": null + } + ], + "total": 1, + "limit": 50, + "offset": 0 + } + } + } + } + } + } + }, + "/user/registerCheck": { + "post": { + "summary": "Check email registration availability", + "description": "Check if an email address can be used for registration", + "parameters": [ + { + "$ref": "#/components/parameters/app_id" + }, + { + "$ref": "#/components/parameters/request_ts" + }, + { + "$ref": "#/components/parameters/request_sig" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address to check" + }, + "language_code": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "Language code for error messages", + "default": "en" + } + }, + "required": ["email"] + }, + "example": { + "email": "joren@directme.in", + "language_code": "en" + } + } + } + }, + "responses": { + "200": { + "description": "Registration check result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed", "success"], + "description": "Check status" + }, + "errors": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Error message for email field" + } + }, + "nullable": true + } + } + }, + "example": { + "status": "failed", + "errors": { + "email": "email [This email address is already being used.]" + } + } + } + } + } + } + } + } + }, + "components": { + "parameters": { + "app_id": { + "name": "app_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Application ID" + }, + "request_ts": { + "name": "request_ts", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Request timestamp" + }, + "request_sig": { + "name": "request_sig", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Cryptographic Signature (Required)\n\nQobuz requires a custom MD5 signature for almost all API calls. Standard Swagger UI cannot compute MD5 hashes dynamically, so you will need to calculate this manually or use a Pre-request Script in tools like Postman.\nThe Algorithm\n\n Method Name: Take the endpoint path without slashes (e.g., /oauth2/login becomes oauth2login).\n\n Parameters: Collect all query parameters except app_id, request_ts, and request_sig. Sort them alphabetically by key. Concatenate the keys and values without spaces (e.g., password[md5_hash]username[email]).\n\n Timestamp: Add the request_ts (Unix Epoch).\n\n App Secret: Append the hardcoded App Secret.\n\n Hash: Generate an MD5 hash of the final concatenated string.\n\nVerified Android Credentials\n\n App ID: 312369995 (Pass this in the X-App-Id header and app_id query)\n\n App Secret: e79f8b9be485692b0e5f9dd895826368\n\nReference Implementation (Python)\nPython\n\nimport hashlib\n\ndef generate_qobuz_sig(method, params_dict, request_ts, app_secret):\n # 1. Filter out standard params\n filtered = {k: v for k, v in params_dict.items() if k not in ['app_id', 'request_ts', 'request_sig']}\n # 2. Sort alphabetically by key and concatenate\n sorted_params = \"\".join(f\"{k}{v}\" for k, v in sorted(filtered.items()))\n # 3. Concatenate all parts\n raw_string = f\"{method}{sorted_params}{request_ts}{app_secret}\"\n # 4. Return MD5 Hash\n return hashlib.md5(raw_string.encode('utf-8')).hexdigest()" + } + }, + "securitySchemes": { + "UserToken": { + "type": "apiKey", + "in": "header", + "name": "X-User-Auth-Token", + "description": "The session token returned by /oauth2/login. Required for all user-specific endpoints." + } + }, + "schemas": { + "Album": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Album ID" + }, + "title": { + "type": "string", + "description": "Album title" + }, + "artist": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + }, + "medium": { + "type": "string" + }, + "large": { + "type": "string" + }, + "extralarge": { + "type": "string" + } + } + }, + "release_date": { + "type": "string", + "format": "date" + }, + "duration": { + "type": "integer", + "description": "Duration in seconds" + }, + "tracks_count": { + "type": "integer" + }, + "media_count": { + "type": "integer" + }, + "genre": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "copyright": { + "type": "string" + }, + "is_preorderable": { + "type": "boolean" + }, + "is_streamable": { + "type": "boolean" + }, + "is_purchasable": { + "type": "boolean" + }, + "is_super_high_res": { + "type": "boolean" + }, + "is_high_res": { + "type": "boolean" + }, + "is_lossless": { + "type": "boolean" + }, + "is_mp3": { + "type": "boolean" + } + } + }, + "PaginatedResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean", + "description": "Whether more items are available" + }, + "items": { + "type": "array", + "description": "Array of items" + }, + "total": { + "type": "integer", + "description": "Total number of items available" + }, + "limit": { + "type": "integer", + "description": "Maximum number of items returned" + }, + "offset": { + "type": "integer", + "description": "Offset of the first item returned" + } + } + }, + "ArtistImage": { + "type": "object", + "properties": { + "artist_id": { + "type": "string" + }, + "image_url": { + "type": "string" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "format": { + "type": "string" + } + } + }, + "ArtistImageRequest": { + "type": "object", + "required": ["artist_ids"], + "properties": { + "artist_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of artist IDs to fetch images for" + } + } + }, + "AlbumSearchResult": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "artist": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + } + } + }, + "release_date": { + "type": "string" + }, + "streamable": { + "type": "boolean" + }, + "duration": { + "type": "integer" + }, + "tracks_count": { + "type": "integer" + } + } + }, + "AlbumSuggestion": { + "type": "object", + "properties": { + "algorithm": { + "type": "string", + "description": "Algorithm used for suggestions" + }, + "albums": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Album" + }, + "description": "Suggested albums" + } + } + }, + "Release": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "tracks_count": { + "type": "integer" + }, + "artist": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "object", + "properties": { + "display": { + "type": "string" + } + } + } + } + }, + "artists": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "large": { + "type": "string" + } + } + }, + "label": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "genre": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "release_type": { + "type": "string", + "enum": ["album", "single", "ep"] + }, + "release_tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "duration": { + "type": "integer" + }, + "dates": { + "type": "object", + "properties": { + "download": { + "type": "string", + "format": "date" + }, + "original": { + "type": "string", + "format": "date" + }, + "stream": { + "type": "string", + "format": "date" + } + } + }, + "parental_warning": { + "type": "boolean" + }, + "audio_info": { + "type": "object", + "properties": { + "maximum_bit_depth": { + "type": "integer" + }, + "maximum_channel_count": { + "type": "integer" + }, + "maximum_sampling_rate": { + "type": "number" + } + } + }, + "rights": { + "type": "object", + "properties": { + "purchasable": { + "type": "boolean" + }, + "streamable": { + "type": "boolean" + }, + "downloadable": { + "type": "boolean" + }, + "hires_streamable": { + "type": "boolean" + }, + "hires_purchasable": { + "type": "boolean" + } + } + }, + "awards": { + "type": "array", + "items": {} + }, + "tracks": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "SimilarArtistsResponse": { + "type": "object", + "properties": { + "artists": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Artist" + } + } + } + } + } + }, + "Artist": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "albums_count": { + "type": "integer" + }, + "picture": { + "type": "string" + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + }, + "medium": { + "type": "string" + }, + "large": { + "type": "string" + }, + "extralarge": { + "type": "string" + }, + "mega": { + "type": "string" + } + } + } + } + }, + "ArtistPage": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "object", + "properties": { + "display": { + "type": "string" + } + } + }, + "artist_category": { + "type": "string" + }, + "biography": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "source": { + "type": "string" + }, + "language": { + "type": "string" + } + } + }, + "images": { + "type": "object", + "properties": { + "portrait": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "format": { + "type": "string" + } + } + } + } + }, + "similar_artists": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Artist" + } + } + } + }, + "top_tracks": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "ArtistSearchResponse": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "artists": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "analytics": { + "type": "object", + "properties": { + "search_external_id": { + "type": "string" + } + } + }, + "total": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Artist" + } + } + } + } + } + }, + "AwardAlbum": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "version": { + "type": "string" + }, + "track_count": { + "type": "integer" + }, + "duration": { + "type": "integer" + }, + "parental_warning": { + "type": "boolean" + }, + "image": { + "type": "object", + "properties": { + "small": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "large": { + "type": "string" + } + } + }, + "artists": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "label": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "genre": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "dates": { + "type": "object", + "properties": { + "download": { + "type": "string", + "format": "date" + }, + "original": { + "type": "string", + "format": "date" + }, + "purchase": { + "type": "string", + "format": "date" + }, + "stream": { + "type": "string", + "format": "date" + } + } + }, + "awards": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "awarded_at": { + "type": "string", + "format": "date" + } + } + } + }, + "audio_info": { + "type": "object", + "properties": { + "maximum_sampling_rate": { + "type": "number" + }, + "maximum_bit_depth": { + "type": "integer" + }, + "maximum_channel_count": { + "type": "integer" + } + } + }, + "rights": { + "type": "object", + "properties": { + "purchasable": { + "type": "boolean" + }, + "streamable": { + "type": "boolean" + }, + "downloadable": { + "type": "boolean" + }, + "hires_streamable": { + "type": "boolean" + }, + "hires_purchasable": { + "type": "boolean" + } + } + } + } + }, + "DiscoverContainer": { + "type": "object", + "properties": { + "banners": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + }, + "new_releases": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + } + } + }, + "Track": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Track ID" + }, + "title": { + "type": "string", + "description": "Track title" + }, + "version": { + "type": "string", + "nullable": true, + "description": "Track version (e.g., 'Remix', 'Live')" + }, + "duration": { + "type": "integer", + "description": "Duration in seconds" + }, + "track_number": { + "type": "integer", + "description": "Track number on album" + }, + "media_number": { + "type": "integer", + "description": "Media number (disc number)" + }, + "parental_warning": { + "type": "boolean", + "description": "Whether track has explicit content" + }, + "maximum_bit_depth": { + "type": "integer", + "description": "Maximum bit depth available" + }, + "maximum_sampling_rate": { + "type": "number", + "description": "Maximum sampling rate in kHz" + }, + "maximum_channel_count": { + "type": "integer", + "description": "Maximum channel count (e.g., 2 for stereo)" + }, + "copyright": { + "type": "string", + "description": "Copyright information" + }, + "isrc": { + "type": "string", + "description": "ISRC code" + }, + "performers": { + "type": "string", + "description": "Performer credits" + }, + "performer": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "composer": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "work": { + "type": "object", + "nullable": true + }, + "audio_info": { + "type": "object", + "properties": { + "replaygain_track_peak": { + "type": "number" + }, + "replaygain_track_gain": { + "type": "number" + } + } + }, + "album": { + "$ref": "#/components/schemas/Album" + }, + "release_date_original": { + "type": "string", + "format": "date" + }, + "release_date_download": { + "type": "string", + "format": "date" + }, + "release_date_stream": { + "type": "string", + "format": "date" + }, + "release_date_purchase": { + "type": "string", + "format": "date" + }, + "purchasable": { + "type": "boolean" + }, + "streamable": { + "type": "boolean" + }, + "previewable": { + "type": "boolean" + }, + "sampleable": { + "type": "boolean" + }, + "downloadable": { + "type": "boolean" + }, + "displayable": { + "type": "boolean" + }, + "purchasable_at": { + "type": "integer", + "nullable": true + }, + "streamable_at": { + "type": "integer" + }, + "hires": { + "type": "boolean" + }, + "hires_streamable": { + "type": "boolean" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "User ID" + }, + "publicId": { + "type": "string", + "description": "Public user identifier" + }, + "email": { + "type": "string", + "format": "email" + }, + "login": { + "type": "string" + }, + "firstname": { + "type": "string", + "nullable": true + }, + "lastname": { + "type": "string", + "nullable": true + }, + "display_name": { + "type": "string" + }, + "country_code": { + "type": "string", + "minLength": 2, + "maxLength": 2 + }, + "language_code": { + "type": "string", + "minLength": 2, + "maxLength": 2 + }, + "zone": { + "type": "string" + }, + "store": { + "type": "string" + }, + "country": { + "type": "string" + }, + "avatar": { + "type": "string", + "format": "uri" + }, + "genre": { + "type": "string", + "enum": ["male", "female", "other"] + }, + "age": { + "type": "integer" + }, + "birthdate": { + "type": "string", + "format": "date" + }, + "creation_date": { + "type": "string", + "format": "date" + }, + "zipcode": { + "type": "string", + "nullable": true + }, + "subscription": { + "type": "object", + "properties": { + "offer": { + "type": "string", + "enum": ["studio", "sublime", "premier", "family"] + }, + "periodicity": { + "type": "string", + "enum": ["monthly", "annual", "quarterly"] + }, + "start_date": { + "type": "string", + "format": "date" + }, + "end_date": { + "type": "string", + "format": "date" + }, + "is_canceled": { + "type": "boolean" + }, + "household_size_max": { + "type": "integer" + } + } + }, + "credential": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "lossy_streaming": { + "type": "boolean" + }, + "lossless_streaming": { + "type": "boolean" + }, + "hires_streaming": { + "type": "boolean" + }, + "hires_purchases_streaming": { + "type": "boolean" + }, + "mobile_streaming": { + "type": "boolean" + }, + "offline_streaming": { + "type": "boolean" + }, + "hfp_purchase": { + "type": "boolean" + }, + "included_format_group_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "color_scheme": { + "type": "object", + "properties": { + "logo": { + "type": "string" + } + } + }, + "label": { + "type": "string" + }, + "short_label": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + } + }, + "last_update": { + "type": "object", + "properties": { + "favorite": { + "type": "integer" + }, + "favorite_album": { + "type": "integer" + }, + "favorite_artist": { + "type": "integer" + }, + "favorite_track": { + "type": "integer" + }, + "favorite_label": { + "type": "integer" + }, + "favorite_award": { + "type": "integer" + }, + "playlist": { + "type": "integer" + }, + "purchase": { + "type": "integer" + } + } + }, + "store_features": { + "type": "object", + "properties": { + "download": { + "type": "boolean" + }, + "streaming": { + "type": "boolean" + }, + "editorial": { + "type": "boolean" + }, + "club": { + "type": "boolean" + }, + "wallet": { + "type": "boolean" + }, + "weeklyq": { + "type": "boolean" + }, + "autoplay": { + "type": "boolean" + }, + "inapp_purchase_subscripton": { + "type": "boolean" + }, + "opt_in": { + "type": "boolean" + }, + "pre_register_opt_in": { + "type": "boolean" + }, + "pre_register_zipcode": { + "type": "boolean" + }, + "music_import": { + "type": "boolean" + }, + "radio": { + "type": "boolean" + }, + "stream_purchase": { + "type": "boolean" + }, + "lyrics": { + "type": "boolean" + } + } + } + } + }, + "Playlist": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "image": { + "type": "object", + "properties": { + "rectangle": { + "type": "string" + }, + "covers": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "description": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "tracks_count": { + "type": "integer" + }, + "genres": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index c874855..f832dd5 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -37,6 +37,8 @@ enum QobuzEvent { EV_USER_OK = 23, EV_ARTIST_RELEASES_OK = 24, EV_DEEP_SHUFFLE_OK = 25, + EV_GENRES_OK = 27, + EV_FEATURED_ALBUMS_OK = 28, }; // Callback signature @@ -53,7 +55,6 @@ void qobuz_backend_get_user(QobuzBackendOpaque *backend); // Catalog void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); -void qobuz_backend_most_popular_search(QobuzBackendOpaque *backend, const char *query, uint32_t limit); void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id); void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id); void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit); @@ -89,6 +90,10 @@ void qobuz_backend_get_artist_releases(QobuzBackendOpaque *backend, int64_t arti // Deep shuffle: fetch tracks from multiple albums (album_ids_json is a JSON array of strings) void qobuz_backend_get_albums_tracks(QobuzBackendOpaque *backend, const char *album_ids_json); +// Browse +void qobuz_backend_get_genres(QobuzBackendOpaque *backend); +void qobuz_backend_get_featured_albums(QobuzBackendOpaque *backend, int64_t genre_id, const char *kind, uint32_t limit, uint32_t offset); + // Playlist management void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name); void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id); diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 9221b56..de5a223 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -40,7 +40,11 @@ fn b64url_decode(s: &str) -> Result> { /// Full qbz-1 key derivation: /// Phase 1: HKDF-SHA256(ikm=hex(app_secret), salt=b64url(infos[0]), info=b64url(infos[1])) → 16-byte KEK /// Phase 2: AES-128-CBC/NoPadding(key=KEK, iv=b64url(key_field[2])).decrypt(b64url(key_field[1]))[..16] -fn derive_track_key(session_infos: &str, app_secret_hex: &str, key_field: &str) -> Result<[u8; 16]> { +fn derive_track_key( + session_infos: &str, + app_secret_hex: &str, + key_field: &str, +) -> Result<[u8; 16]> { // Phase 1: HKDF let infos_parts: Vec<&str> = session_infos.splitn(2, '.').collect(); if infos_parts.len() != 2 { @@ -63,7 +67,11 @@ fn derive_track_key(session_infos: &str, app_secret_hex: &str, key_field: &str) let ct = b64url_decode(key_parts[1])?; let iv_bytes = b64url_decode(key_parts[2])?; if ct.len() < 16 || iv_bytes.len() < 16 { - bail!("key field ciphertext/iv too short ({} / {} bytes)", ct.len(), iv_bytes.len()); + bail!( + "key field ciphertext/iv too short ({} / {} bytes)", + ct.len(), + iv_bytes.len() + ); } let iv: [u8; 16] = iv_bytes[..16].try_into()?; @@ -202,7 +210,10 @@ impl QobuzClient { let status = resp.status(); let body: Value = resp.json().await?; if !status.is_success() { - let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed"); + let msg = body + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("login failed"); bail!("login failed ({}): {}", status, msg); } @@ -238,9 +249,7 @@ impl QobuzClient { } let ts = Self::ts(); - let mut sign_params: Vec<(&str, String)> = vec![ - ("profile", "qbz-1".to_string()), - ]; + let mut sign_params: Vec<(&str, String)> = vec![("profile", "qbz-1".to_string())]; let sig = self.request_sig("sessionstart", &mut sign_params, ts); let resp = self @@ -251,7 +260,10 @@ impl QobuzClient { ("request_ts", ts.to_string().as_str()), ("request_sig", sig.as_str()), ]) - .header("Authorization", format!("Bearer {}", self.auth_token.as_deref().unwrap_or(""))) + .header( + "Authorization", + format!("Bearer {}", self.auth_token.as_deref().unwrap_or("")), + ) .form(&[("profile", "qbz-1")]) .send() .await?; @@ -263,7 +275,12 @@ impl QobuzClient { .to_string(); let expires_at = body["expires_at"].as_u64().unwrap_or(now + 3600); let infos = body["infos"].as_str().map(|s| s.to_string()); - eprintln!("[session] started session_id={}... expires_at={} infos={:?}", &session_id[..session_id.len().min(8)], expires_at, infos); + eprintln!( + "[session] started session_id={}... expires_at={} infos={:?}", + &session_id[..session_id.len().min(8)], + expires_at, + infos + ); self.session_id = Some(session_id); self.session_expires_at = Some(expires_at); self.session_infos = infos; @@ -292,7 +309,11 @@ impl QobuzClient { Ok(serde_json::from_value(body)?) } - pub async fn get_track_url(&mut self, track_id: i64, format: Format) -> Result { + pub async fn get_track_url( + &mut self, + track_id: i64, + format: Format, + ) -> Result { self.ensure_session().await?; let ts = Self::ts(); @@ -317,11 +338,15 @@ impl QobuzClient { .await?; let body = Self::check_response(resp).await?; - eprintln!("[file/url] response: {}", serde_json::to_string(&body).unwrap_or_default()); + eprintln!( + "[file/url] response: {}", + serde_json::to_string(&body).unwrap_or_default() + ); let mut url_dto: TrackFileUrlDto = serde_json::from_value(body)?; // Unwrap the per-track key: decrypt the CBC-wrapped key using HKDF-derived KEK. - if let (Some(key_field), Some(infos)) = (url_dto.key.clone(), self.session_infos.as_deref()) { + if let (Some(key_field), Some(infos)) = (url_dto.key.clone(), self.session_infos.as_deref()) + { match derive_track_key(infos, &self.app_secret, &key_field) { Ok(track_key) => { url_dto.key = Some(hex::encode(track_key)); @@ -370,12 +395,39 @@ impl QobuzClient { let resp = self .get_request("artist/getReleasesList") .query(&[ - ("artist_id", artist_id.to_string()), + ("artist_id", artist_id.to_string()), ("release_type", release_type.to_string()), - ("sort", "release_date".to_string()), - ("order", "desc".to_string()), - ("limit", limit.to_string()), - ("offset", offset.to_string()), + ("sort", "release_date".to_string()), + ("order", "desc".to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ]) + .send() + .await?; + Self::check_response(resp).await + } + + // --- Browse --- + + pub async fn get_genres(&self) -> Result { + let resp = self.get_request("genre/list").send().await?; + Self::check_response(resp).await + } + + pub async fn get_featured_albums( + &self, + genre_id: i64, + kind: &str, + limit: u32, + offset: u32, + ) -> Result { + let resp = self + .get_request("album/getFeatured") + .query(&[ + ("type", kind.to_string()), + ("genre_id", genre_id.to_string()), + ("limit", limit.to_string()), + ("offset", offset.to_string()), ]) .send() .await?; @@ -384,54 +436,74 @@ impl QobuzClient { // --- Search --- - pub async fn most_popular_search(&self, query: &str, limit: u32) -> Result { - let resp = self - .get_request("most-popular/get") - .query(&[("query", query), ("offset", "0"), ("limit", &limit.to_string())]) - .send() - .await?; - Self::check_response(resp).await - } - pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result { - let (tracks, albums, artists) = tokio::try_join!( + let (tracks_res, albums_res, artists_res) = tokio::join!( self.search_tracks(query, offset, limit), self.search_albums(query, offset, limit), self.search_artists(query, offset, limit), - )?; + ); + + // Convert successful Results into Some(value) and Errors into None Ok(SearchCatalogDto { query: Some(query.to_string()), - albums: Some(albums), - tracks: Some(tracks), - artists: Some(artists), + tracks: tracks_res.ok(), + albums: albums_res.ok(), + artists: artists_res.ok(), playlists: None, }) } - async fn search_tracks(&self, query: &str, offset: u32, limit: u32) -> Result> { + async fn search_tracks( + &self, + query: &str, + offset: u32, + limit: u32, + ) -> Result> { let resp = self .get_request("track/search") - .query(&[("query", query), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .query(&[ + ("query", query), + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body["tracks"].clone())?) } - async fn search_albums(&self, query: &str, offset: u32, limit: u32) -> Result> { + async fn search_albums( + &self, + query: &str, + offset: u32, + limit: u32, + ) -> Result> { let resp = self .get_request("album/search") - .query(&[("query", query), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .query(&[ + ("query", query), + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body["albums"].clone())?) } - async fn search_artists(&self, query: &str, offset: u32, limit: u32) -> Result> { + async fn search_artists( + &self, + query: &str, + offset: u32, + limit: u32, + ) -> Result> { let resp = self .get_request("artist/search") - .query(&[("query", query), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .query(&[ + ("query", query), + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) .send() .await?; let body = Self::check_response(resp).await?; @@ -443,14 +515,22 @@ impl QobuzClient { pub async fn get_user_playlists(&self, offset: u32, limit: u32) -> Result { let resp = self .get_request("playlist/getUserPlaylists") - .query(&[("offset", &offset.to_string()), ("limit", &limit.to_string())]) + .query(&[ + ("offset", &offset.to_string()), + ("limit", &limit.to_string()), + ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } - pub async fn get_playlist(&self, playlist_id: i64, offset: u32, limit: u32) -> Result { + pub async fn get_playlist( + &self, + playlist_id: i64, + offset: u32, + limit: u32, + ) -> Result { let resp = self .get_request("playlist/get") .query(&[ @@ -486,14 +566,16 @@ impl QobuzClient { .send() .await?; let body = Self::check_response(resp).await?; - let items: Vec = serde_json::from_value( - body["tracks"]["items"].clone(), - ) - .unwrap_or_default(); + let items: Vec = + serde_json::from_value(body["tracks"]["items"].clone()).unwrap_or_default(); Ok(items) } - pub async fn get_fav_tracks(&self, offset: u32, limit: u32) -> Result> { + pub async fn get_fav_tracks( + &self, + offset: u32, + limit: u32, + ) -> Result> { let ids = self.get_fav_ids().await?; let all_ids = ids.tracks.unwrap_or_default(); let total = all_ids.len() as i32; @@ -511,7 +593,11 @@ impl QobuzClient { }) } - pub async fn get_fav_albums(&self, offset: u32, limit: u32) -> Result> { + pub async fn get_fav_albums( + &self, + offset: u32, + limit: u32, + ) -> Result> { let ids = self.get_fav_ids().await?; let all_ids = ids.albums.unwrap_or_default(); let total = all_ids.len() as i32; @@ -536,7 +622,11 @@ impl QobuzClient { }) } - pub async fn get_fav_artists(&self, offset: u32, limit: u32) -> Result> { + pub async fn get_fav_artists( + &self, + offset: u32, + limit: u32, + ) -> Result> { let ids = self.get_fav_ids().await?; let all_ids = ids.artists.unwrap_or_default(); let total = all_ids.len() as i32; @@ -549,8 +639,40 @@ impl QobuzClient { for artist_id in page { match self.get_artist_page(artist_id).await { Ok(v) => { - if let Ok(a) = serde_json::from_value::(v) { - items.push(a); + let id = v.get("id").and_then(|i| i.as_i64()); + let name = v + .get("name") + .and_then(|n| n.get("display")) + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + + let mut image_dto = None; + if let Some(imgs) = v.get("images") { + if let Some(portrait) = imgs.get("portrait") { + if let (Some(hash), Some(format)) = ( + portrait.get("hash").and_then(|h| h.as_str()), + portrait.get("format").and_then(|f| f.as_str()), + ) { + image_dto = Some(ImageDto { + small: Some(format!("https://static.qobuz.com/images/artists/covers/small/{}.{}", hash, format)), + thumbnail: Some(format!("https://static.qobuz.com/images/artists/covers/small/{}.{}", hash, format)), + large: Some(format!("https://static.qobuz.com/images/artists/covers/large/{}.{}", hash, format)), + back: None, + }); + } + } + } + + if id.is_some() && name.is_some() { + items.push(FavArtistDto { + id, + name, + albums_count: v + .get("albums_count") + .and_then(|c| c.as_i64()) + .map(|c| c as i32), + image: image_dto, + }); } } Err(e) => eprintln!("[fav] failed to fetch artist {}: {}", artist_id, e), @@ -569,7 +691,11 @@ impl QobuzClient { pub async fn create_playlist(&self, name: &str) -> Result { let resp = self .post_request("playlist/create") - .form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")]) + .form(&[ + ("name", name), + ("is_public", "false"), + ("is_collaborative", "false"), + ]) .send() .await?; let body = Self::check_response(resp).await?; @@ -581,7 +707,7 @@ impl QobuzClient { .post_request("playlist/addTracks") .form(&[ ("playlist_id", playlist_id.to_string()), - ("track_ids", track_id.to_string()), + ("track_ids", track_id.to_string()), ("no_duplicate", "true".to_string()), ]) .send() @@ -600,11 +726,15 @@ impl QobuzClient { Ok(()) } - pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> { + pub async fn delete_track_from_playlist( + &self, + playlist_id: i64, + playlist_track_id: i64, + ) -> Result<()> { let resp = self .post_request("playlist/deleteTracks") .form(&[ - ("playlist_id", playlist_id.to_string()), + ("playlist_id", playlist_id.to_string()), ("playlist_track_ids", playlist_track_id.to_string()), ]) .send() diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a54be9c..b95a7f2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,26 +1,4 @@ //! qobuz-backend: C-ABI library consumed by the Qt frontend. -//! -//! Event type constants – second argument of EventCallback: -//! 0 EV_LOGIN_OK { "token": "…", "user": { … } } -//! 1 EV_LOGIN_ERR { "error": "…" } -//! 2 EV_SEARCH_OK SearchCatalogDto -//! 3 EV_SEARCH_ERR { "error": "…" } -//! 4 EV_ALBUM_OK AlbumDto -//! 5 EV_ALBUM_ERR { "error": "…" } -//! 6 EV_ARTIST_OK ArtistDto -//! 7 EV_ARTIST_ERR { "error": "…" } -//! 8 EV_PLAYLIST_OK PlaylistDto -//! 9 EV_PLAYLIST_ERR { "error": "…" } -//! 10 EV_FAV_TRACKS_OK { "items": […], "total": N } -//! 11 EV_FAV_ALBUMS_OK { "items": […], "total": N } -//! 12 EV_FAV_ARTISTS_OK { "items": […], "total": N } -//! 13 EV_PLAYLISTS_OK { "items": […], "total": N } -//! 14 EV_TRACK_CHANGED TrackDto -//! 15 EV_STATE_CHANGED { "state": "playing"|"paused"|"idle"|"error" } -//! 16 EV_POSITION { "position": u64, "duration": u64 } -//! 17 EV_TRACK_URL_OK TrackFileUrlDto -//! 18 EV_TRACK_URL_ERR { "error": "…" } -//! 19 EV_GENERIC_ERR { "error": "…" } mod api; mod player; @@ -38,9 +16,6 @@ use tokio::sync::Mutex; // ---------- Send-safe raw pointer wrapper ---------- -/// Wraps a `*mut c_void` so it can cross thread boundaries. -/// SAFETY: The Qt QobuzBackend object is kept alive for the Backend's lifetime -/// and callbacks only call QMetaObject::invokeMethod (thread-safe Qt API). #[derive(Clone, Copy)] struct SendPtr(*mut c_void); unsafe impl Send for SendPtr {} @@ -70,7 +45,8 @@ pub const EV_TRACK_URL_ERR: c_int = 18; pub const EV_GENERIC_ERR: c_int = 19; pub const EV_ARTIST_RELEASES_OK: c_int = 24; pub const EV_DEEP_SHUFFLE_OK: c_int = 25; -pub const EV_MOST_POPULAR_OK: c_int = 26; +pub const EV_GENRES_OK: c_int = 27; +pub const EV_FEATURED_ALBUMS_OK: c_int = 28; // ---------- Callback ---------- @@ -102,7 +78,6 @@ pub struct Backend(BackendInner); // ---------- Helpers ---------- fn call_cb(cb: EventCallback, ud: SendPtr, ev: c_int, json: &str) { - // Strip null bytes that would cause CString::new to fail let safe = json.replace('\0', ""); let cstr = CString::new(safe).unwrap_or_else(|_| CString::new("{}").unwrap()); unsafe { cb(ud.0, ev, cstr.as_ptr()) }; @@ -112,7 +87,6 @@ fn err_json(msg: &str) -> String { serde_json::json!({ "error": msg }).to_string() } -/// Spawn a Send + 'static future on the backend's Tokio runtime. fn spawn(inner: &BackendInner, f: F) where F: std::future::Future + Send + 'static, @@ -163,26 +137,33 @@ pub unsafe extern "C" fn qobuz_backend_login( email: *const c_char, password: *const c_char, ) { - let inner = &(*ptr).0; - let email = CStr::from_ptr(email).to_string_lossy().into_owned(); + let inner = &(*ptr).0; + let email = CStr::from_ptr(email).to_string_lossy().into_owned(); let password = CStr::from_ptr(password).to_string_lossy().into_owned(); - let client = inner.client.clone(); - let cb = inner.cb; - let ud = inner.ud; + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.login(&email, &password).await; let (ev, json) = match result { Ok(resp) => { let token = resp - .oauth2.as_ref().and_then(|o| o.access_token.as_deref()) + .oauth2 + .as_ref() + .and_then(|o| o.access_token.as_deref()) .or(resp.user_auth_token.as_deref()) .unwrap_or("") .to_string(); - let user_val = resp.user.as_ref() + let user_val = resp + .user + .as_ref() .map(|u| serde_json::to_value(u).unwrap_or_default()) .unwrap_or_default(); - (EV_LOGIN_OK, serde_json::json!({"token": token, "user": user_val}).to_string()) + ( + EV_LOGIN_OK, + serde_json::json!({"token": token, "user": user_val}).to_string(), + ) } Err(e) => (EV_LOGIN_ERR, err_json(&e.to_string())), }; @@ -194,8 +175,6 @@ pub unsafe extern "C" fn qobuz_backend_login( pub unsafe extern "C" fn qobuz_backend_set_token(ptr: *mut Backend, token: *const c_char) { let inner = &(*ptr).0; let token = CStr::from_ptr(token).to_string_lossy().into_owned(); - // Use blocking_lock (called from Qt main thread, not a tokio thread) so the - // token is set before any subsequent getUser/library requests are spawned. inner.client.blocking_lock().set_auth_token(token); } @@ -207,27 +186,6 @@ pub unsafe extern "C" fn qobuz_backend_search( query: *const c_char, offset: u32, limit: u32, -) { - let inner = &(*ptr).0; - let query = CStr::from_ptr(query).to_string_lossy().into_owned(); - let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; - - spawn(inner, async move { - let result = client.lock().await.search(&query, offset, limit).await; - let (ev, json) = match result { - Ok(r) => (EV_SEARCH_OK, serde_json::to_string(&r).unwrap_or_default()), - Err(e) => (EV_SEARCH_ERR, err_json(&e.to_string())), - }; - call_cb(cb, ud, ev, &json); - }); -} - -#[no_mangle] -pub unsafe extern "C" fn qobuz_backend_most_popular_search( - ptr: *mut Backend, - query: *const c_char, - limit: u32, ) { let inner = &(*ptr).0; let query = CStr::from_ptr(query).to_string_lossy().into_owned(); @@ -236,10 +194,10 @@ pub unsafe extern "C" fn qobuz_backend_most_popular_search( let ud = inner.ud; spawn(inner, async move { - let result = client.lock().await.most_popular_search(&query, limit).await; + let result = client.lock().await.search(&query, offset, limit).await; let (ev, json) = match result { - Ok(r) => (EV_MOST_POPULAR_OK, serde_json::to_string(&r).unwrap_or_default()), - Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + Ok(r) => (EV_SEARCH_OK, serde_json::to_string(&r).unwrap_or_default()), + Err(e) => (EV_SEARCH_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); }); @@ -249,15 +207,16 @@ pub unsafe extern "C" fn qobuz_backend_most_popular_search( #[no_mangle] pub unsafe extern "C" fn qobuz_backend_get_album(ptr: *mut Backend, album_id: *const c_char) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_album(&album_id).await; let (ev, json) = match result { - Ok(r) => (EV_ALBUM_OK, serde_json::to_string(&r).unwrap_or_default()), + Ok(r) => (EV_ALBUM_OK, serde_json::to_string(&r).unwrap_or_default()), Err(e) => (EV_ALBUM_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); @@ -268,14 +227,15 @@ pub unsafe extern "C" fn qobuz_backend_get_album(ptr: *mut Backend, album_id: *c #[no_mangle] pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id: i64) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_artist_page(artist_id).await; let (ev, json) = match result { - Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()), + Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()), Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); @@ -292,17 +252,19 @@ pub unsafe extern "C" fn qobuz_backend_get_artist_releases( limit: u32, _offset: u32, ) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; let rtype = CStr::from_ptr(release_type).to_string_lossy().into_owned(); spawn(inner, async move { - // Auto-paginate: fetch all pages until has_more is false. let mut all_items: Vec = Vec::new(); let mut offset: u32 = 0; loop { - let result = client.lock().await + let result = client + .lock() + .await .get_artist_releases_list(artist_id, &rtype, limit, offset) .await; match result { @@ -311,7 +273,10 @@ pub unsafe extern "C" fn qobuz_backend_get_artist_releases( if let Some(items) = obj.get("items").and_then(|v| v.as_array()) { all_items.extend(items.iter().cloned()); } - let has_more = obj.get("has_more").and_then(|v| v.as_bool()).unwrap_or(false); + let has_more = obj + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); if !has_more { break; } @@ -329,7 +294,12 @@ pub unsafe extern "C" fn qobuz_backend_get_artist_releases( "has_more": false, "offset": 0 }); - call_cb(cb, ud, EV_ARTIST_RELEASES_OK, &serde_json::to_string(&result).unwrap_or_default()); + call_cb( + cb, + ud, + EV_ARTIST_RELEASES_OK, + &serde_json::to_string(&result).unwrap_or_default(), + ); }); } @@ -342,8 +312,11 @@ pub unsafe extern "C" fn qobuz_backend_get_albums_tracks( ) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; - let ids_str = CStr::from_ptr(album_ids_json).to_string_lossy().into_owned(); + let cb = inner.cb; + let ud = inner.ud; + let ids_str = CStr::from_ptr(album_ids_json) + .to_string_lossy() + .into_owned(); let album_ids: Vec = match serde_json::from_str(&ids_str) { Ok(v) => v, @@ -360,17 +333,18 @@ pub unsafe extern "C" fn qobuz_backend_get_albums_tracks( if let Ok(album) = result { if let Some(tracks) = album.tracks.as_ref().and_then(|t| t.items.as_ref()) { for t in tracks { - // Serialize track and inject album info for playback context if let Ok(mut tv) = serde_json::to_value(t) { if let Some(obj) = tv.as_object_mut() { - // Ensure album context is present on each track if obj.get("album").is_none() || obj["album"].is_null() { - obj.insert("album".to_string(), serde_json::json!({ - "id": album.id, - "title": album.title, - "artist": album.artist, - "image": album.image, - })); + obj.insert( + "album".to_string(), + serde_json::json!({ + "id": album.id, + "title": album.title, + "artist": album.artist, + "image": album.image, + }), + ); } } all_tracks.push(tv); @@ -378,10 +352,86 @@ pub unsafe extern "C" fn qobuz_backend_get_albums_tracks( } } } - // Skip albums that fail — don't abort the whole operation } let result = serde_json::json!({ "tracks": all_tracks }); - call_cb(cb, ud, EV_DEEP_SHUFFLE_OK, &serde_json::to_string(&result).unwrap_or_default()); + call_cb( + cb, + ud, + EV_DEEP_SHUFFLE_OK, + &serde_json::to_string(&result).unwrap_or_default(), + ); + }); +} + +// ---------- Playlist ---------- + +// ---------- Browse (genres / featured) ---------- + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_genres(ptr: *mut Backend) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; + + spawn(inner, async move { + let result = client.lock().await.get_genres().await; + match result { + Ok(r) => { + let items = r["genres"]["items"].clone(); + let total = r["genres"]["total"].as_i64().unwrap_or(0); + let out = serde_json::json!({"items": items, "total": total}); + call_cb( + cb, + ud, + EV_GENRES_OK, + &serde_json::to_string(&out).unwrap_or_default(), + ); + } + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } + }); +} + +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_featured_albums( + ptr: *mut Backend, + genre_id: i64, + kind: *const c_char, + limit: u32, + offset: u32, +) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; + let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned(); + + spawn(inner, async move { + let result = client + .lock() + .await + .get_featured_albums(genre_id, &kind_str, limit, offset) + .await; + match result { + Ok(r) => { + let items = r["albums"]["items"].clone(); + let total = r["albums"]["total"].as_i64().unwrap_or(0); + let out = serde_json::json!({ + "items": items, + "total": total, + "type": kind_str, + "genre_id": genre_id, + }); + call_cb( + cb, + ud, + EV_FEATURED_ALBUMS_OK, + &serde_json::to_string(&out).unwrap_or_default(), + ); + } + Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), + } }); } @@ -394,14 +444,22 @@ pub unsafe extern "C" fn qobuz_backend_get_playlist( offset: u32, limit: u32, ) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { - let result = client.lock().await.get_playlist(playlist_id, offset, limit).await; + let result = client + .lock() + .await + .get_playlist(playlist_id, offset, limit) + .await; let (ev, json) = match result { - Ok(r) => (EV_PLAYLIST_OK, serde_json::to_string(&r).unwrap_or_default()), + Ok(r) => ( + EV_PLAYLIST_OK, + serde_json::to_string(&r).unwrap_or_default(), + ), Err(e) => (EV_PLAYLIST_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); @@ -414,12 +472,16 @@ pub unsafe extern "C" fn qobuz_backend_get_playlist( pub unsafe extern "C" fn qobuz_backend_get_fav_tracks(ptr: *mut Backend, offset: u32, limit: u32) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_fav_tracks(offset, limit).await; let (ev, json) = match result { - Ok(r) => (EV_FAV_TRACKS_OK, serde_json::to_string(&r).unwrap_or_default()), - Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + Ok(r) => ( + EV_FAV_TRACKS_OK, + serde_json::to_string(&r).unwrap_or_default(), + ), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); }); @@ -429,12 +491,16 @@ pub unsafe extern "C" fn qobuz_backend_get_fav_tracks(ptr: *mut Backend, offset: pub unsafe extern "C" fn qobuz_backend_get_fav_albums(ptr: *mut Backend, offset: u32, limit: u32) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_fav_albums(offset, limit).await; let (ev, json) = match result { - Ok(r) => (EV_FAV_ALBUMS_OK, serde_json::to_string(&r).unwrap_or_default()), - Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + Ok(r) => ( + EV_FAV_ALBUMS_OK, + serde_json::to_string(&r).unwrap_or_default(), + ), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); }); @@ -444,29 +510,46 @@ pub unsafe extern "C" fn qobuz_backend_get_fav_albums(ptr: *mut Backend, offset: pub unsafe extern "C" fn qobuz_backend_get_fav_artists(ptr: *mut Backend, offset: u32, limit: u32) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_fav_artists(offset, limit).await; let (ev, json) = match result { - Ok(r) => (EV_FAV_ARTISTS_OK, serde_json::to_string(&r).unwrap_or_default()), - Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), + Ok(r) => ( + EV_FAV_ARTISTS_OK, + serde_json::to_string(&r).unwrap_or_default(), + ), + Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); }); } #[no_mangle] -pub unsafe extern "C" fn qobuz_backend_get_user_playlists(ptr: *mut Backend, offset: u32, limit: u32) { +pub unsafe extern "C" fn qobuz_backend_get_user_playlists( + ptr: *mut Backend, + offset: u32, + limit: u32, +) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_user_playlists(offset, limit).await; let (ev, json) = match result { Ok(r) => { - let items = r.playlists.as_ref().and_then(|p| p.items.as_ref()).cloned().unwrap_or_default(); + let items = r + .playlists + .as_ref() + .and_then(|p| p.items.as_ref()) + .cloned() + .unwrap_or_default(); let total = r.playlists.as_ref().and_then(|p| p.total).unwrap_or(0); - (EV_PLAYLISTS_OK, serde_json::json!({"items": items, "total": total}).to_string()) + ( + EV_PLAYLISTS_OK, + serde_json::json!({"items": items, "total": total}).to_string(), + ) } Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }; @@ -482,9 +565,10 @@ pub unsafe extern "C" fn qobuz_backend_play_track( track_id: i64, format_id: i32, ) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; let format = Format::from_id(format_id); let cmd_tx = inner.player.cmd_tx.clone(); let status = inner.player.status.clone(); @@ -492,10 +576,13 @@ pub unsafe extern "C" fn qobuz_backend_play_track( let rg_enabled = inner.replaygain_enabled.clone(); spawn(inner, async move { - // 1. Check prefetch cache first for zero-gap start let cached = { let mut lock = prefetch.lock().await; - if lock.as_ref().map(|p| p.track_id == track_id).unwrap_or(false) { + if lock + .as_ref() + .map(|p| p.track_id == track_id) + .unwrap_or(false) + { lock.take() } else { None @@ -504,58 +591,71 @@ pub unsafe extern "C" fn qobuz_backend_play_track( // Extract prefetch_data to embed directly into TrackInfo let (track, url, n_segments, encryption_key, prefetch_data) = if let Some(pf) = cached { - (pf.track, pf.url, pf.n_segments, pf.encryption_key, pf.prefetch_data) + ( + pf.track, + pf.url, + pf.n_segments, + pf.encryption_key, + pf.prefetch_data, + ) } else { - // Fetch track metadata let track = match client.lock().await.get_track(track_id).await { Ok(t) => t, - Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + Err(e) => { + call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); + return; + } }; - // Fetch stream URL let url_dto = match client.lock().await.get_track_url(track_id, format).await { Ok(u) => u, - Err(e) => { call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); return; } + Err(e) => { + call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json(&e.to_string())); + return; + } }; let encryption_key = url_dto.key.clone(); - // Prefer segmented url_template (reliable CDN path), fall back to plain url - eprintln!("[lib] url_dto: url={:?}, url_template={:?}, n_segments={:?}, mime={:?}, key_present={}", - url_dto.url.as_deref().map(|u| &u[..u.len().min(60)]), - url_dto.url_template.as_deref().map(|u| &u[..u.len().min(60)]), - url_dto.n_segments, - url_dto.mime_type, - encryption_key.is_some()); - let (url, n_segments) = if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) { - (tmpl, n) - } else if let Some(u) = url_dto.url { - (u, 0u32) - } else { - call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); - return; - }; - eprintln!("[lib] resolved: n_segments={n_segments}, url_prefix={}", &url[..url.len().min(80)]); + + let (url, n_segments) = + if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) { + (tmpl, n) + } else if let Some(u) = url_dto.url { + (u, 0u32) + } else { + call_cb(cb, ud, EV_TRACK_URL_ERR, &err_json("no stream URL")); + return; + }; (track, url, n_segments, encryption_key, None) }; - // 2. Notify track change if let Ok(j) = serde_json::to_string(&track) { call_cb(cb, ud, EV_TRACK_CHANGED, &j); } - // 3. Compute ReplayGain if enabled let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) { - track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain) + track + .audio_info + .as_ref() + .and_then(|ai| ai.replaygain_track_gain) } else { None }; - // 4. Update status + send play command *status.current_track.lock().unwrap() = Some(track.clone()); if let Some(dur) = track.duration { - status.duration_secs.store(dur as u64, std::sync::atomic::Ordering::Relaxed); + status + .duration_secs + .store(dur as u64, std::sync::atomic::Ordering::Relaxed); } - let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { track, url, n_segments, encryption_key, replaygain_db, prefetch_data })); - // 5. State notification + let _ = cmd_tx.send(player::PlayerCommand::Play(player::TrackInfo { + track, + url, + n_segments, + encryption_key, + replaygain_db, + prefetch_data, + })); + call_cb(cb, ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); }); } @@ -564,14 +664,24 @@ pub unsafe extern "C" fn qobuz_backend_play_track( pub unsafe extern "C" fn qobuz_backend_pause(ptr: *mut Backend) { let inner = &(*ptr).0; inner.player.pause(); - call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"paused"}"#); + call_cb( + inner.cb, + inner.ud, + EV_STATE_CHANGED, + r#"{"state":"paused"}"#, + ); } #[no_mangle] pub unsafe extern "C" fn qobuz_backend_resume(ptr: *mut Backend) { let inner = &(*ptr).0; inner.player.resume(); - call_cb(inner.cb, inner.ud, EV_STATE_CHANGED, r#"{"state":"playing"}"#); + call_cb( + inner.cb, + inner.ud, + EV_STATE_CHANGED, + r#"{"state":"playing"}"#, + ); } #[no_mangle] @@ -610,16 +720,24 @@ pub unsafe extern "C" fn qobuz_backend_get_volume(ptr: *const Backend) -> u8 { pub unsafe extern "C" fn qobuz_backend_get_state(ptr: *const Backend) -> c_int { match (*ptr).0.player.status.get_state() { PlayerState::Playing => 1, - PlayerState::Paused => 2, - _ => 0, + PlayerState::Paused => 2, + _ => 0, } } #[no_mangle] pub unsafe extern "C" fn qobuz_backend_take_track_finished(ptr: *mut Backend) -> c_int { - let finished = (*ptr).0.player.status.track_finished + let finished = (*ptr) + .0 + .player + .status + .track_finished .swap(false, std::sync::atomic::Ordering::SeqCst); - if finished { 1 } else { 0 } + if finished { + 1 + } else { + 0 + } } #[no_mangle] @@ -648,12 +766,20 @@ pub unsafe extern "C" fn qobuz_backend_take_track_transitioned(ptr: *mut Backend #[no_mangle] pub unsafe extern "C" fn qobuz_backend_set_replaygain(ptr: *mut Backend, enabled: bool) { - (*ptr).0.replaygain_enabled.store(enabled, std::sync::atomic::Ordering::Relaxed); + (*ptr) + .0 + .replaygain_enabled + .store(enabled, std::sync::atomic::Ordering::Relaxed); } #[no_mangle] pub unsafe extern "C" fn qobuz_backend_set_gapless(ptr: *mut Backend, enabled: bool) { - (*ptr).0.player.status.gapless.store(enabled, std::sync::atomic::Ordering::Relaxed); + (*ptr) + .0 + .player + .status + .gapless + .store(enabled, std::sync::atomic::Ordering::Relaxed); } #[no_mangle] @@ -678,13 +804,14 @@ pub unsafe extern "C" fn qobuz_backend_prefetch_track( Err(_) => return, }; let encryption_key = url_dto.key.clone(); - let (url, n_segments) = if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) { - (tmpl, n) - } else if let Some(u) = url_dto.url { - (u, 0u32) - } else { - return; - }; + let (url, n_segments) = + if let (Some(tmpl), Some(n)) = (url_dto.url_template, url_dto.n_segments) { + (tmpl, n) + } else if let Some(u) = url_dto.url { + (u, 0u32) + } else { + return; + }; // KICKSTART DOWNLOADING IMMEDIATELY let prefetch_data = if n_segments > 0 { @@ -699,7 +826,10 @@ pub unsafe extern "C" fn qobuz_backend_prefetch_track( }; let replaygain_db = if rg_enabled.load(std::sync::atomic::Ordering::Relaxed) { - track.audio_info.as_ref().and_then(|ai| ai.replaygain_track_gain) + track + .audio_info + .as_ref() + .and_then(|ai| ai.replaygain_track_gain) } else { None }; @@ -721,7 +851,8 @@ pub unsafe extern "C" fn qobuz_backend_prefetch_track( pub unsafe extern "C" fn qobuz_backend_add_fav_track(ptr: *mut Backend, track_id: i64) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.add_fav_track(track_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -733,7 +864,8 @@ pub unsafe extern "C" fn qobuz_backend_add_fav_track(ptr: *mut Backend, track_id pub unsafe extern "C" fn qobuz_backend_remove_fav_track(ptr: *mut Backend, track_id: i64) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.remove_fav_track(track_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -743,10 +875,11 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_track(ptr: *mut Backend, track #[no_mangle] pub unsafe extern "C" fn qobuz_backend_add_fav_album(ptr: *mut Backend, album_id: *const c_char) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.add_fav_album(&album_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -755,11 +888,15 @@ pub unsafe extern "C" fn qobuz_backend_add_fav_album(ptr: *mut Backend, album_id } #[no_mangle] -pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album_id: *const c_char) { - let inner = &(*ptr).0; +pub unsafe extern "C" fn qobuz_backend_remove_fav_album( + ptr: *mut Backend, + album_id: *const c_char, +) { + let inner = &(*ptr).0; let album_id = CStr::from_ptr(album_id).to_string_lossy().into_owned(); let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.remove_fav_album(&album_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -771,7 +908,8 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album pub unsafe extern "C" fn qobuz_backend_add_fav_artist(ptr: *mut Backend, artist_id: i64) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.add_fav_artist(artist_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -783,7 +921,8 @@ pub unsafe extern "C" fn qobuz_backend_add_fav_artist(ptr: *mut Backend, artist_ pub unsafe extern "C" fn qobuz_backend_remove_fav_artist(ptr: *mut Backend, artist_id: i64) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { if let Err(e) = client.lock().await.remove_fav_artist(artist_id).await { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); @@ -799,11 +938,12 @@ pub const EV_USER_OK: c_int = 23; pub unsafe extern "C" fn qobuz_backend_get_user(ptr: *mut Backend) { let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { let result = client.lock().await.get_user().await; let (ev, json) = match result { - Ok(r) => (EV_USER_OK, serde_json::to_string(&r).unwrap_or_default()), + Ok(r) => (EV_USER_OK, serde_json::to_string(&r).unwrap_or_default()), Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())), }; call_cb(cb, ud, ev, &json); @@ -819,12 +959,18 @@ pub const EV_PLAYLIST_TRACK_ADDED: c_int = 22; #[no_mangle] pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: *const c_char) { let inner = &(*ptr).0; - let name = CStr::from_ptr(name).to_string_lossy().into_owned(); + let name = CStr::from_ptr(name).to_string_lossy().into_owned(); let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { match client.lock().await.create_playlist(&name).await { - Ok(p) => call_cb(cb, ud, EV_PLAYLIST_CREATED, &serde_json::to_string(&p).unwrap_or_default()), + Ok(p) => call_cb( + cb, + ud, + EV_PLAYLIST_CREATED, + &serde_json::to_string(&p).unwrap_or_default(), + ), Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), } }); @@ -832,13 +978,18 @@ pub unsafe extern "C" fn qobuz_backend_create_playlist(ptr: *mut Backend, name: #[no_mangle] pub unsafe extern "C" fn qobuz_backend_delete_playlist(ptr: *mut Backend, playlist_id: i64) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { match client.lock().await.delete_playlist(playlist_id).await { - Ok(()) => call_cb(cb, ud, EV_PLAYLIST_DELETED, - &serde_json::json!({"playlist_id": playlist_id}).to_string()), + Ok(()) => call_cb( + cb, + ud, + EV_PLAYLIST_DELETED, + &serde_json::json!({"playlist_id": playlist_id}).to_string(), + ), Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), } }); @@ -850,13 +1001,23 @@ pub unsafe extern "C" fn qobuz_backend_add_track_to_playlist( playlist_id: i64, track_id: i64, ) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { - match client.lock().await.add_track_to_playlist(playlist_id, track_id).await { - Ok(()) => call_cb(cb, ud, EV_PLAYLIST_TRACK_ADDED, - &serde_json::json!({"playlist_id": playlist_id}).to_string()), + match client + .lock() + .await + .add_track_to_playlist(playlist_id, track_id) + .await + { + Ok(()) => call_cb( + cb, + ud, + EV_PLAYLIST_TRACK_ADDED, + &serde_json::json!({"playlist_id": playlist_id}).to_string(), + ), Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())), } }); @@ -868,11 +1029,17 @@ pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist( playlist_id: i64, playlist_track_id: i64, ) { - let inner = &(*ptr).0; + let inner = &(*ptr).0; let client = inner.client.clone(); - let cb = inner.cb; let ud = inner.ud; + let cb = inner.cb; + let ud = inner.ud; spawn(inner, async move { - if let Err(e) = client.lock().await.delete_track_from_playlist(playlist_id, playlist_track_id).await { + if let Err(e) = client + .lock() + .await + .delete_track_from_playlist(playlist_id, playlist_track_id) + .await + { call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())); } }); diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index 64e9b3f..6d50f62 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -141,7 +141,7 @@ fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatu match rx.recv_timeout(Duration::from_millis(100)) { Ok(PlayerCommand::Play(info)) => break info, Ok(PlayerCommand::QueueNext(info)) => { - // If completely idle and get QueueNext, treat as Play + // If we are completely idle and get QueueNext, treat as Play break info; } Ok(PlayerCommand::Stop) => { @@ -155,7 +155,7 @@ fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatu Ok(PlayerCommand::SetVolume(v)) => { status.volume.store(v, Ordering::Relaxed); } - Ok(_) => {} + Ok(_) => {} // Ignore Pause/Resume when idle Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break 'outer, } @@ -176,9 +176,11 @@ fn player_loop(rx: std::sync::mpsc::Receiver, status: PlayerStatu status.position_secs.store(0, Ordering::Relaxed); paused.store(false, Ordering::SeqCst); + // TrackInfo now directly passes the prefetch_data (if it exists) to the decoder match decoder::play_track_inline(info, &status, &paused, &mut audio_output, &rx) { Ok(Some(NextAction::Play(next_track))) => { pending_action = Some(NextAction::Play(next_track)); + // Interrupted by a manual play, no need to tell C++ to advance the queue } Ok(Some(NextAction::Transition(next_track))) => { pending_action = Some(NextAction::Play(next_track)); diff --git a/rust/src/player/output.rs b/rust/src/player/output.rs index b0ec8f4..c1aa828 100644 --- a/rust/src/player/output.rs +++ b/rust/src/player/output.rs @@ -3,7 +3,7 @@ use cpal::{ traits::{DeviceTrait, HostTrait, StreamTrait}, StreamConfig, }; -use rb::{RbConsumer, RbProducer, SpscRb, RB}; +use rb::{RbConsumer, RbInspector, RbProducer, SpscRb, RB}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -13,6 +13,7 @@ use symphonia::core::audio::AudioBufferRef; const RING_BUFFER_SIZE: usize = 32 * 1024; pub struct AudioOutput { + _ring: SpscRb, ring_buf_producer: rb::Producer, _stream: cpal::Stream, pub sample_rate: u32, @@ -50,6 +51,7 @@ impl AudioOutput { stream.play()?; Ok(Self { + _ring: ring, ring_buf_producer: producer, _stream: stream, sample_rate, @@ -87,4 +89,13 @@ impl AudioOutput { } Ok(()) } + + pub fn flush(&self) { + // Wait until the ring buffer is fully emptied by cpal + while !self._ring.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // Give the physical DAC an extra 100ms to output the last samples + std::thread::sleep(std::time::Duration::from_millis(100)); + } } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a9e52c2..7de5188 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -26,6 +26,8 @@ target_sources(qobuz-qt PRIVATE view/artistlistview.hpp view/artistview.hpp view/artistview.cpp + view/genrebrowser.hpp + view/genrebrowser.cpp view/sidepanel/view.hpp view/sidepanel/view.cpp @@ -56,3 +58,10 @@ target_sources(qobuz-qt PRIVATE util/icon.hpp util/settings.hpp ) + +if (USE_DBUS) + target_sources(qobuz-qt PRIVATE + backend/mpris.hpp + backend/mpris.cpp + ) +endif () diff --git a/src/backend/mpris.cpp b/src/backend/mpris.cpp new file mode 100644 index 0000000..174f57d --- /dev/null +++ b/src/backend/mpris.cpp @@ -0,0 +1,29 @@ +#include "mpris.hpp" + +#include +#include +#include + +MprisRootAdaptor::MprisRootAdaptor(QObject *parent) + : QDBusAbstractAdaptor(parent) +{ +} + +MprisPlayerAdaptor::MprisPlayerAdaptor(QObject *parent) + : QDBusAbstractAdaptor(parent) +{ +} + +Mpris::Mpris(QObject *parent) + : QObject(parent) +{ + m_root = new MprisRootAdaptor(this); + m_player = new MprisPlayerAdaptor(this); + + QDBusConnection dbus = QDBusConnection::sessionBus(); + QString serviceName = QString("org.mpris.MediaPlayer2.qobuz_qt.instance%1").arg(QCoreApplication::applicationPid()); + + dbus.registerService(serviceName); + dbus.registerObject("/org/mpris/MediaPlayer2", this, + QDBusConnection::ExportAdaptors); +} diff --git a/src/backend/mpris.hpp b/src/backend/mpris.hpp new file mode 100644 index 0000000..369392c --- /dev/null +++ b/src/backend/mpris.hpp @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class MprisRootAdaptor : public QDBusAbstractAdaptor +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2") + + Q_PROPERTY(bool CanQuit READ canQuit) + Q_PROPERTY(bool Fullscreen READ fullscreen WRITE setFullscreen) + Q_PROPERTY(bool CanSetFullscreen READ canSetFullscreen) + Q_PROPERTY(bool CanRaise READ canRaise) + Q_PROPERTY(bool HasTrackList READ hasTrackList) + Q_PROPERTY(QString Identity READ identity) + Q_PROPERTY(QString DesktopEntry READ desktopEntry) + Q_PROPERTY(QStringList SupportedUriSchemes READ supportedUriSchemes) + Q_PROPERTY(QStringList SupportedMimeTypes READ supportedMimeTypes) + +public: + explicit MprisRootAdaptor(QObject *parent); + + bool canQuit() const { return true; } + bool fullscreen() const { return false; } + void setFullscreen(bool) {} + bool canSetFullscreen() const { return false; } + bool canRaise() const { return true; } + bool hasTrackList() const { return false; } + QString identity() const { return "Qobuz"; } + QString desktopEntry() const { return "qobuz-qt"; } + QStringList supportedUriSchemes() const { return {}; } + QStringList supportedMimeTypes() const { return {}; } + +public slots: + void Quit() { emit quitRequested(); } + void Raise() { emit raiseRequested(); } + +signals: + void quitRequested(); + void raiseRequested(); +}; + +class MprisPlayerAdaptor : public QDBusAbstractAdaptor +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.mpris.MediaPlayer2.Player") + + Q_PROPERTY(QString PlaybackStatus READ playbackStatus NOTIFY playbackStatusChanged) + Q_PROPERTY(QString LoopStatus READ loopStatus WRITE setLoopStatus) + Q_PROPERTY(double Rate READ rate WRITE setRate) + Q_PROPERTY(bool Shuffle READ shuffle WRITE setShuffle) + Q_PROPERTY(QVariantMap Metadata READ metadata NOTIFY metadataChanged) + Q_PROPERTY(double Volume READ volume WRITE setVolume NOTIFY volumeChanged) + Q_PROPERTY(qlonglong Position READ position) + Q_PROPERTY(double MinimumRate READ minimumRate) + Q_PROPERTY(double MaximumRate READ maximumRate) + Q_PROPERTY(bool CanGoNext READ canGoNext) + Q_PROPERTY(bool CanGoPrevious READ canGoPrevious) + Q_PROPERTY(bool CanPlay READ canPlay) + Q_PROPERTY(bool CanPause READ canPause) + Q_PROPERTY(bool CanSeek READ canSeek) + Q_PROPERTY(bool CanControl READ canControl) + +public: + explicit MprisPlayerAdaptor(QObject *parent); + + QString playbackStatus() const { return m_playbackStatus; } + void setPlaybackStatus(const QString &s) { + if (m_playbackStatus != s) { + m_playbackStatus = s; + emit playbackStatusChanged(); + } + } + + QString loopStatus() const { return "None"; } + void setLoopStatus(const QString &) {} + + double rate() const { return 1.0; } + void setRate(double) {} + + bool shuffle() const { return false; } + void setShuffle(bool) {} + + QVariantMap metadata() const { return m_metadata; } + void setMetadata(const QVariantMap &m) { + m_metadata = m; + emit metadataChanged(); + } + + double volume() const { return m_volume; } + void setVolume(double v) { + emit volumeChangeRequested(v); + } + void updateVolume(double v) { + if (m_volume != v) { + m_volume = v; + emit volumeChanged(); + } + } + + qlonglong position() const { return m_positionMicro; } + void updatePosition(qlonglong posSecs) { m_positionMicro = posSecs * 1000000LL; } + + double minimumRate() const { return 1.0; } + double maximumRate() const { return 1.0; } + + bool canGoNext() const { return true; } + bool canGoPrevious() const { return true; } + bool canPlay() const { return true; } + bool canPause() const { return true; } + bool canSeek() const { return true; } + bool canControl() const { return true; } + +public slots: + void Next() { emit nextRequested(); } + void Previous() { emit previousRequested(); } + void Pause() { emit pauseRequested(); } + void PlayPause() { emit playPauseRequested(); } + void Stop() { emit stopRequested(); } + void Play() { emit playRequested(); } + void Seek(qlonglong offset) { emit seekRequested(offset); } + void SetPosition(const QDBusObjectPath &trackId, qlonglong position) { Q_UNUSED(trackId); emit seekToRequested(position); } + void OpenUri(const QString &uri) { Q_UNUSED(uri); } + +signals: + void playbackStatusChanged(); + void metadataChanged(); + void volumeChanged(); + + // Commands to MainWindow + void playRequested(); + void pauseRequested(); + void playPauseRequested(); + void stopRequested(); + void nextRequested(); + void previousRequested(); + void seekRequested(qlonglong offsetMicroseconds); + void seekToRequested(qlonglong positionMicroseconds); + void volumeChangeRequested(double volume); + +private: + QString m_playbackStatus = "Stopped"; + QVariantMap m_metadata; + double m_volume = 1.0; + qlonglong m_positionMicro = 0; +}; + +class Mpris : public QObject +{ + Q_OBJECT +public: + explicit Mpris(QObject *parent = nullptr); + + MprisRootAdaptor *root() const { return m_root; } + MprisPlayerAdaptor *player() const { return m_player; } + +private: + MprisRootAdaptor *m_root; + MprisPlayerAdaptor *m_player; +}; diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 9fbaa0d..94dd8fa 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -51,11 +51,6 @@ void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit) qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit); } -void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit) -{ - qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), limit); -} - void QobuzBackend::getAlbum(const QString &albumId) { qobuz_backend_get_album(m_backend, albumId.toUtf8().constData()); @@ -84,6 +79,16 @@ void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit) qobuz_backend_get_playlist(m_backend, playlistId, offset, limit); } +void QobuzBackend::getGenres() +{ + qobuz_backend_get_genres(m_backend); +} + +void QobuzBackend::getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit, quint32 offset) +{ + qobuz_backend_get_featured_albums(m_backend, genreId, kind.toUtf8().constData(), limit, offset); +} + // ---- favorites ---- void QobuzBackend::getFavTracks(quint32 offset, quint32 limit) @@ -222,7 +227,7 @@ void QobuzBackend::onPositionTick() if (qobuz_backend_take_track_finished(m_backend)) emit trackFinished(); - + if (qobuz_backend_take_track_transitioned(m_backend)) emit trackTransitioned(); } @@ -246,9 +251,6 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case EV_SEARCH_OK: emit searchResult(obj); break; - case 26: // EV_MOST_POPULAR_OK - emit mostPopularResult(obj); - break; case EV_SEARCH_ERR: emit error(obj["error"].toString()); break; @@ -272,6 +274,12 @@ void QobuzBackend::onEvent(int eventType, const QString &json) case 25: // EV_DEEP_SHUFFLE_OK emit deepShuffleTracksLoaded(obj["tracks"].toArray()); break; + case 27: // EV_GENRES_OK + emit genresLoaded(obj); + break; + case 28: // EV_FEATURED_ALBUMS_OK + emit featuredAlbumsLoaded(obj); + break; case EV_ARTIST_ERR: emit error(obj["error"].toString()); break; diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 79cdc0c..3921816 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -28,12 +28,13 @@ public: // --- catalog --- void search(const QString &query, quint32 offset = 0, quint32 limit = 20); - void mostPopularSearch(const QString &query, quint32 limit = 30); void getAlbum(const QString &albumId); void getArtist(qint64 artistId); void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); void getAlbumsTracks(const QStringList &albumIds); void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500); + void getGenres(); + void getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit = 50, quint32 offset = 0); // --- favorites --- void getFavTracks(quint32 offset = 0, quint32 limit = 500); @@ -82,11 +83,12 @@ signals: // catalog void searchResult(const QJsonObject &result); - void mostPopularResult(const QJsonObject &result); void albumLoaded(const QJsonObject &album); void artistLoaded(const QJsonObject &artist); void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void deepShuffleTracksLoaded(const QJsonArray &tracks); + void genresLoaded(const QJsonObject &result); + void featuredAlbumsLoaded(const QJsonObject &result); void playlistLoaded(const QJsonObject &playlist); void playlistCreated(const QJsonObject &playlist); void playlistDeleted(const QJsonObject &result); @@ -112,6 +114,11 @@ private slots: Q_INVOKABLE void onEvent(int eventType, const QString &json); void onPositionTick(); +public: + void manuallyEmitTrackChanged(const QJsonObject &track) { + emit trackChanged(track); + } + private: QobuzBackendOpaque *m_backend = nullptr; QTimer *m_positionTimer = nullptr; diff --git a/src/list/library.cpp b/src/list/library.cpp index fdbe433..d00158b 100644 --- a/src/list/library.cpp +++ b/src/list/library.cpp @@ -20,6 +20,7 @@ enum NodeType { NodeFavAlbums, NodeFavArtists, NodePlaylist, + NodeBrowseGenres, }; Library::Library(QobuzBackend *backend, QWidget *parent) @@ -71,6 +72,13 @@ void Library::buildStaticNodes() // Playlists m_playlistsNode = new QTreeWidgetItem(this, QStringList{tr("Playlists")}); m_playlistsNode->setExpanded(true); + + // Browse + m_browseNode = new QTreeWidgetItem(this, QStringList{tr("Browse")}); + m_browseNode->setExpanded(true); + + auto *genresItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Genres")}); + genresItem->setData(0, TypeRole, NodeBrowseGenres); } void Library::refresh() @@ -167,6 +175,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int) case NodeFavTracks: emit favTracksRequested(); break; case NodeFavAlbums: emit favAlbumsRequested(); break; case NodeFavArtists: emit favArtistsRequested(); break; + case NodeBrowseGenres: emit browseGenresRequested(); break; case NodePlaylist: { const qint64 id = item->data(0, IdRole).toLongLong(); const QString name = item->data(0, NameRole).toString(); diff --git a/src/list/library.hpp b/src/list/library.hpp index 529aa2c..06d46ee 100644 --- a/src/list/library.hpp +++ b/src/list/library.hpp @@ -26,6 +26,7 @@ namespace List void favTracksRequested(); void favAlbumsRequested(); void favArtistsRequested(); + void browseGenresRequested(); void playlistRequested(qint64 playlistId, const QString &name); /// Emitted after playlists are loaded so others can cache the list. void userPlaylistsChanged(const QVector> &playlists); @@ -43,6 +44,7 @@ namespace List QTreeWidgetItem *m_myLibNode = nullptr; QTreeWidgetItem *m_playlistsNode = nullptr; + QTreeWidgetItem *m_browseNode = nullptr; qint64 m_openPlaylistId = 0; void buildStaticNodes(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 80eee57..eff6e2e 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -4,6 +4,10 @@ #include "util/settings.hpp" #include "util/icon.hpp" +#ifdef USE_DBUS +#include "backend/mpris.hpp" +#endif + #include #include #include @@ -61,17 +65,37 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) statusBar()->showMessage(tr("Ready")); // ---- Scrobbler ---- - m_scrobbler = new LastFmScrobbler(this); - connect(m_backend, &QobuzBackend::trackChanged, - m_scrobbler, &LastFmScrobbler::onTrackStarted); - connect(m_backend, &QobuzBackend::positionChanged, - m_scrobbler, &LastFmScrobbler::onPositionChanged); - connect(m_backend, &QobuzBackend::trackFinished, - m_scrobbler, &LastFmScrobbler::onTrackFinished); + m_scrobbler = new LastFmScrobbler(this); + connect(m_backend, &QobuzBackend::trackChanged, + m_scrobbler, &LastFmScrobbler::onTrackStarted); + connect(m_backend, &QobuzBackend::positionChanged, + m_scrobbler, &LastFmScrobbler::onPositionChanged); + connect(m_backend, &QobuzBackend::trackFinished, + m_scrobbler, &LastFmScrobbler::onTrackFinished); - // Scrobble the finished track during a gapless transition - connect(m_backend, &QobuzBackend::trackTransitioned, - m_scrobbler, &LastFmScrobbler::onTrackFinished); + // 1. Scrobble the finished track during a gapless transition + connect(m_backend, &QobuzBackend::trackTransitioned, + m_scrobbler, &LastFmScrobbler::onTrackFinished); + + // ---- Gapless Signal ---- + connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos, quint64 dur) { + if (!AppSettings::instance().gaplessEnabled() || dur == 0) return; + + // Trigger prefetch if we pass the 50% mark OR are within 60 seconds of the end + if ((pos > dur / 2) || (dur > 60 && (dur - pos) <= 60)) { + if (!m_nextTrackPrefetched && m_queue->canGoNext()) { + m_nextTrackPrefetched = true; // Lock it so it only fires once + + const auto upcoming = m_queue->upcomingTracks(1); + if (!upcoming.isEmpty()) { + const qint64 nextId = static_cast(upcoming.first()["id"].toDouble()); + if (nextId > 0) { + m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat()); + } + } + } + } + }); // ---- Backend signals ---- connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); @@ -122,6 +146,52 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) m_backend->getFavTracks(); statusBar()->showMessage(tr("Loading favorite tracks…")); }); + +#ifdef USE_DBUS + m_mpris = new Mpris(this); + connect(m_mpris->player(), &MprisPlayerAdaptor::playRequested, m_backend, [this] { + if (m_backend->state() == 2) m_backend->resume(); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::pauseRequested, m_backend, &QobuzBackend::pause); + connect(m_mpris->player(), &MprisPlayerAdaptor::playPauseRequested, m_backend, [this] { + if (m_backend->state() == 1) + m_backend->pause(); + else + m_backend->resume(); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::stopRequested, m_backend, &QobuzBackend::stop); + connect(m_mpris->player(), &MprisPlayerAdaptor::nextRequested, this, [this] { + if (!m_queue->canGoNext()) return; + const qint64 id = static_cast(m_queue->advance()["id"].toDouble()); + if (id > 0) m_backend->playTrack(id); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::previousRequested, this, [this] { + if (!m_queue->canGoPrev()) return; + const qint64 id = static_cast(m_queue->stepBack()["id"].toDouble()); + if (id > 0) m_backend->playTrack(id); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::seekRequested, m_backend, [this](qlonglong offsetMicroseconds) { + qint64 newPos = m_backend->position() + (offsetMicroseconds / 1000000LL); + if (newPos < 0) newPos = 0; + m_backend->seek(newPos); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::seekToRequested, m_backend, [this](qlonglong positionMicroseconds) { + m_backend->seek(positionMicroseconds / 1000000LL); + }); + connect(m_mpris->player(), &MprisPlayerAdaptor::volumeChangeRequested, m_backend, [this](double vol) { + m_backend->setVolume(vol * 100); + }); + + connect(m_backend, &QobuzBackend::stateChanged, this, [this](const QString &state) { + if (state == "playing") m_mpris->player()->setPlaybackStatus("Playing"); + else if (state == "paused") m_mpris->player()->setPlaybackStatus("Paused"); + else m_mpris->player()->setPlaybackStatus("Stopped"); + }); + connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos) { + m_mpris->player()->updatePosition(pos); + }); +#endif + connect(m_library, &List::Library::favAlbumsRequested, this, [this] { m_backend->getFavAlbums(); statusBar()->showMessage(tr("Loading favorite albums…")); @@ -136,6 +206,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) m_backend->getPlaylist(id); statusBar()->showMessage(tr("Loading playlist: %1…").arg(name)); }); + connect(m_library, &List::Library::browseGenresRequested, this, [this] { + m_content->showGenreBrowser(); + statusBar()->showMessage(tr("Browse Genres")); + }); // ---- Track list → playback / playlist management ---- connect(m_content->tracksList(), &List::Tracks::playTrackRequested, @@ -289,6 +363,7 @@ void MainWindow::onLoginError(const QString &error) void MainWindow::onTrackChanged(const QJsonObject &track) { + m_nextTrackPrefetched = false; // Update playing row highlight in the track list const qint64 id = static_cast(track["id"].toDouble()); m_content->tracksList()->setPlayingTrackId(id); @@ -301,15 +376,25 @@ void MainWindow::onTrackChanged(const QJsonObject &track) statusBar()->showMessage( artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title)); - // Prefetch next track URL when gapless is enabled - if (AppSettings::instance().gaplessEnabled() && m_queue->canGoNext()) { - const auto upcoming = m_queue->upcomingTracks(1); - if (!upcoming.isEmpty()) { - const qint64 nextId = static_cast(upcoming.first()["id"].toDouble()); - if (nextId > 0) - m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat()); - } +#ifdef USE_DBUS + QVariantMap metadata; + metadata["mpris:trackid"] = QVariant::fromValue(QDBusObjectPath(QString("/org/qobuz/track/%1").arg(id))); + metadata["mpris:length"] = QVariant::fromValue(qlonglong(track["duration"].toDouble() * 1000000LL)); + metadata["xesam:title"] = title; + + QJsonObject album = track["album"].toObject(); + metadata["xesam:album"] = album["title"].toString(); + + if (!artist.isEmpty()) { + metadata["xesam:artist"] = QStringList{artist}; } + + if (album.contains("image") && album["image"].toObject().contains("large")) { + metadata["mpris:artUrl"] = album["image"].toObject()["large"].toString(); + } + + m_mpris->player()->setMetadata(metadata); +#endif } void MainWindow::onFavTracksLoaded(const QJsonObject &result) @@ -420,4 +505,3 @@ void MainWindow::onUserPlaylistsChanged(const QVector> &p m_content->tracksList()->setUserPlaylists(playlists); m_sidePanel->searchTab()->setUserPlaylists(playlists); } - diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp index b3668bf..4554f4a 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -19,6 +19,8 @@ #include #include +class Mpris; + class MainWindow : public QMainWindow { Q_OBJECT @@ -62,7 +64,9 @@ private: QueuePanel *m_queuePanel = nullptr; SidePanel::View *m_sidePanel = nullptr; QDockWidget *m_libraryDock = nullptr; - LastFmScrobbler *m_scrobbler = nullptr; + LastFmScrobbler *m_scrobbler = nullptr; + Mpris *m_mpris = nullptr; + bool m_nextTrackPrefetched = false; void setupMenuBar(); void tryRestoreSession(); diff --git a/src/view/albumlistview.hpp b/src/view/albumlistview.hpp index 2a654ff..d3ab386 100644 --- a/src/view/albumlistview.hpp +++ b/src/view/albumlistview.hpp @@ -80,6 +80,7 @@ public: const QString year = date.isEmpty() ? a["dates"].toObject()["original"].toString().left(4) : date.left(4); + const qint64 artistId = static_cast(a["artist"].toObject()["id"].toDouble()); const int tracks = a["tracks_count"].toInt(); const bool hiRes = a["hires_streamable"].toBool() @@ -97,6 +98,7 @@ public: item->setText(3, year); item->setText(4, tracks > 0 ? QString::number(tracks) : QString()); item->setData(1, Qt::UserRole, id); + item->setData(2, Qt::UserRole, artistId); } } diff --git a/src/view/genrebrowser.cpp b/src/view/genrebrowser.cpp new file mode 100644 index 0000000..80e9e91 --- /dev/null +++ b/src/view/genrebrowser.cpp @@ -0,0 +1,122 @@ +#include "genrebrowser.hpp" + +#include +#include +#include +#include +#include +#include + +GenreBrowserView::GenreBrowserView(QobuzBackend *backend, QWidget *parent) + : QWidget(parent) + , m_backend(backend) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *topBar = new QWidget(this); + auto *topLayout = new QHBoxLayout(topBar); + topLayout->setContentsMargins(8, 6, 8, 6); + + topLayout->addWidget(new QLabel(tr("Genre:"), this)); + m_genreCombo = new QComboBox(this); + m_genreCombo->setMinimumWidth(160); + topLayout->addWidget(m_genreCombo); + + topLayout->addSpacing(16); + + topLayout->addWidget(new QLabel(tr("Type:"), this)); + m_typeCombo = new QComboBox(this); + m_typeCombo->addItem(tr("New Releases"), QStringLiteral("new-releases")); + m_typeCombo->addItem(tr("Best Sellers"), QStringLiteral("best-sellers")); + m_typeCombo->addItem(tr("Most Streamed"), QStringLiteral("most-streamed")); + m_typeCombo->addItem(tr("Editor Picks"), QStringLiteral("editor-picks")); + m_typeCombo->addItem(tr("Press Awards"), QStringLiteral("press-awards")); + topLayout->addWidget(m_typeCombo); + + topLayout->addStretch(); + layout->addWidget(topBar); + + m_albumList = new AlbumListView(this); + m_albumList->setContextMenuPolicy(Qt::CustomContextMenu); + layout->addWidget(m_albumList, 1); + + connect(m_backend, &QobuzBackend::genresLoaded, + this, &GenreBrowserView::onGenresLoaded); + connect(m_backend, &QobuzBackend::featuredAlbumsLoaded, + this, &GenreBrowserView::onFeaturedAlbumsLoaded); + connect(m_genreCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &GenreBrowserView::onSelectionChanged); + connect(m_typeCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &GenreBrowserView::onSelectionChanged); + connect(m_albumList, &AlbumListView::albumSelected, + this, &GenreBrowserView::albumSelected); + connect(m_albumList, &QTreeWidget::customContextMenuRequested, + this, &GenreBrowserView::onAlbumContextMenu); +} + +void GenreBrowserView::ensureGenresLoaded() +{ + if (!m_genresLoaded) + m_backend->getGenres(); +} + +void GenreBrowserView::onGenresLoaded(const QJsonObject &result) +{ + m_genresLoaded = true; + m_genreCombo->blockSignals(true); + m_genreCombo->clear(); + + const QJsonArray items = result["items"].toArray(); + for (const auto &value : items) { + const QJsonObject genre = value.toObject(); + m_genreCombo->addItem( + genre["name"].toString(), + static_cast(genre["id"].toDouble())); + } + + m_genreCombo->blockSignals(false); + onSelectionChanged(); +} + +void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result) +{ + m_albumList->setAlbums(result["items"].toArray()); +} + +void GenreBrowserView::onSelectionChanged() +{ + if (m_genreCombo->count() == 0) + return; + + const qint64 genreId = m_genreCombo->currentData().toLongLong(); + const QString type = m_typeCombo->currentData().toString(); + m_backend->getFeaturedAlbums(genreId, type, 50, 0); +} + +void GenreBrowserView::onAlbumContextMenu(const QPoint &pos) +{ + QTreeWidgetItem *item = m_albumList->itemAt(pos); + if (!item) + return; + + const QString albumId = item->data(1, Qt::UserRole).toString(); + const qint64 artistId = item->data(2, Qt::UserRole).toLongLong(); + + QMenu menu(this); + + auto *openAlbum = menu.addAction(tr("Open Album")); + connect(openAlbum, &QAction::triggered, this, [this, albumId] { + emit albumSelected(albumId); + }); + + if (artistId > 0) { + auto *openArtist = menu.addAction(tr("Open Artist")); + connect(openArtist, &QAction::triggered, this, [this, artistId] { + emit artistSelected(artistId); + }); + } + + menu.exec(m_albumList->viewport()->mapToGlobal(pos)); +} diff --git a/src/view/genrebrowser.hpp b/src/view/genrebrowser.hpp new file mode 100644 index 0000000..d90efd2 --- /dev/null +++ b/src/view/genrebrowser.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "../backend/qobuzbackend.hpp" +#include "albumlistview.hpp" + +#include +#include +#include +#include + +class GenreBrowserView : public QWidget +{ + Q_OBJECT + +public: + explicit GenreBrowserView(QobuzBackend *backend, QWidget *parent = nullptr); + + void ensureGenresLoaded(); + +signals: + void albumSelected(const QString &albumId); + void artistSelected(qint64 artistId); + +private slots: + void onGenresLoaded(const QJsonObject &result); + void onFeaturedAlbumsLoaded(const QJsonObject &result); + void onSelectionChanged(); + void onAlbumContextMenu(const QPoint &pos); + +private: + QobuzBackend *m_backend = nullptr; + QComboBox *m_genreCombo = nullptr; + QComboBox *m_typeCombo = nullptr; + AlbumListView *m_albumList = nullptr; + bool m_genresLoaded = false; +}; diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 195d5ba..2d78ee9 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -45,12 +45,14 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren m_albumList = new AlbumListView(this); m_artistList = new ArtistListView(this); m_artistView = new ArtistView(backend, queue, this); + m_genreBrowser = new GenreBrowserView(backend, this); m_stack->addWidget(m_welcome); // 0 m_stack->addWidget(tracksPage); // 1 m_stack->addWidget(m_albumList); // 2 m_stack->addWidget(m_artistList); // 3 m_stack->addWidget(m_artistView); // 4 + m_stack->addWidget(m_genreBrowser); // 5 m_stack->setCurrentIndex(0); @@ -58,6 +60,8 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested); connect(m_artistView, &ArtistView::albumSelected, this, &MainContent::albumRequested); connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested); + connect(m_genreBrowser, &GenreBrowserView::albumSelected, this, &MainContent::albumRequested); + connect(m_genreBrowser, &GenreBrowserView::artistSelected, this, &MainContent::artistRequested); } void MainContent::showWelcome() { m_stack->setCurrentIndex(0); } @@ -122,3 +126,9 @@ void MainContent::onDeepShuffleTracks(const QJsonArray &tracks) { m_artistView->onDeepShuffleTracks(tracks); } + +void MainContent::showGenreBrowser() +{ + m_genreBrowser->ensureGenresLoaded(); + m_stack->setCurrentIndex(5); +} diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp index d8b3603..106301f 100644 --- a/src/view/maincontent.hpp +++ b/src/view/maincontent.hpp @@ -6,6 +6,7 @@ #include "albumlistview.hpp" #include "artistlistview.hpp" #include "artistview.hpp" +#include "genrebrowser.hpp" #include "trackcontextheader.hpp" #include @@ -34,6 +35,7 @@ public: void updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset); void setFavArtistIds(const QSet &ids); void onDeepShuffleTracks(const QJsonArray &tracks); + void showGenreBrowser(); ArtistView *artistView() const { return m_artistView; } @@ -51,4 +53,5 @@ private: AlbumListView *m_albumList = nullptr; ArtistListView *m_artistList = nullptr; ArtistView *m_artistView = nullptr; + GenreBrowserView *m_genreBrowser = nullptr; }; diff --git a/src/view/maintoolbar.cpp b/src/view/maintoolbar.cpp index b1ce3a1..3512ea2 100644 --- a/src/view/maintoolbar.cpp +++ b/src/view/maintoolbar.cpp @@ -117,6 +117,7 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged); connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged); connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished); + connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned); // ---- Queue signals ---- connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged); @@ -249,6 +250,17 @@ void MainToolBar::onTrackFinished() } } +void MainToolBar::onTrackTransitioned() +{ + if (m_queue->canGoNext()) { + const QJsonObject track = m_queue->advance(); + setCurrentTrack(track); + m_backend->manuallyEmitTrackChanged(track); + } else { + onTrackFinished(); + } +} + void MainToolBar::onQueueChanged() { m_previous->setEnabled(m_queue->canGoPrev()); diff --git a/src/view/maintoolbar.hpp b/src/view/maintoolbar.hpp index 884d242..095a3b3 100644 --- a/src/view/maintoolbar.hpp +++ b/src/view/maintoolbar.hpp @@ -44,6 +44,7 @@ private slots: void onTrackChanged(const QJsonObject &track); void onPositionChanged(quint64 position, quint64 duration); void onTrackFinished(); + void onTrackTransitioned(); void onQueueChanged(); void onShuffleToggled(bool checked); diff --git a/src/view/sidepanel/view.cpp b/src/view/sidepanel/view.cpp index 60325f5..a7e0100 100644 --- a/src/view/sidepanel/view.cpp +++ b/src/view/sidepanel/view.cpp @@ -39,16 +39,6 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) // Result tabs m_resultTabs = new QTabWidget(this); - // Top Results tab (default) — mixed artists/albums/tracks from most-popular endpoint - m_popularResults = new QTreeWidget(this); - m_popularResults->setHeaderLabels({tr(""), tr("Name"), tr("Detail")}); - m_popularResults->setRootIsDecorated(false); - m_popularResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - m_popularResults->header()->setSectionResizeMode(1, QHeaderView::Stretch); - m_popularResults->header()->setSectionResizeMode(2, QHeaderView::Stretch); - m_popularResults->header()->setStretchLastSection(false); - m_popularResults->setContextMenuPolicy(Qt::CustomContextMenu); - m_trackResults = new QTreeWidget(this); m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")}); m_trackResults->setRootIsDecorated(false); @@ -67,26 +57,21 @@ SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) m_artistResults->setHeaderLabels({tr("Artist")}); m_artistResults->setRootIsDecorated(false); - m_resultTabs->addTab(m_popularResults, tr("Top Results")); - m_resultTabs->addTab(m_trackResults, tr("Tracks")); - m_resultTabs->addTab(m_albumResults, tr("Albums")); - m_resultTabs->addTab(m_artistResults, tr("Artists")); + m_resultTabs->addTab(m_trackResults, tr("Tracks")); + m_resultTabs->addTab(m_albumResults, tr("Albums")); + m_resultTabs->addTab(m_artistResults, tr("Artists")); layout->addWidget(m_resultTabs); connect(searchBtn, &QPushButton::clicked, this, &SearchTab::onSearchSubmit); connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit); - connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult); - connect(m_backend, &QobuzBackend::mostPopularResult, this, &SearchTab::onMostPopularResult); + connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult); - connect(m_popularResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); - connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); - connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); - connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); // Context menus - connect(m_popularResults, &QTreeWidget::customContextMenuRequested, - this, &SearchTab::onPopularContextMenu); connect(m_trackResults, &QTreeWidget::customContextMenuRequested, this, &SearchTab::onTrackContextMenu); connect(m_albumResults, &QTreeWidget::customContextMenuRequested, @@ -101,10 +86,8 @@ void SearchTab::setUserPlaylists(const QVector> &playlist void SearchTab::onSearchSubmit() { const QString q = m_searchBox->text().trimmed(); - if (q.isEmpty()) return; - m_backend->mostPopularSearch(q, 30); - m_backend->search(q, 0, 20); - m_resultTabs->setCurrentIndex(0); // show Top Results tab + if (!q.isEmpty()) + m_backend->search(q, 0, 20); } void SearchTab::onSearchResult(const QJsonObject &result) @@ -162,62 +145,6 @@ void SearchTab::onSearchResult(const QJsonObject &result) } } -void SearchTab::onMostPopularResult(const QJsonObject &result) -{ - m_popularResults->clear(); - - QFont badgeFont; - badgeFont.setBold(true); - badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.8); - - const QJsonArray items = result["most_popular"].toObject()["items"].toArray(); - for (const auto &v : items) { - const QJsonObject entry = v.toObject(); - const QString type = entry["type"].toString(); - const QJsonObject content = entry["content"].toObject(); - - QString badge, name, detail; - QColor badgeColor; - - if (type == QStringLiteral("artists")) { - badge = QStringLiteral("A"); - badgeColor = QColor(QStringLiteral("#6699CC")); - name = content["name"].toString(); - detail = tr("%1 albums").arg(static_cast(content["albums_count"].toDouble())); - } else if (type == QStringLiteral("albums")) { - const bool hiRes = content["hires_streamable"].toBool(); - badge = hiRes ? QStringLiteral("H") : QStringLiteral("A"); - badgeColor = hiRes ? QColor(QStringLiteral("#FFB232")) : QColor(QStringLiteral("#AAAAAA")); - name = content["title"].toString(); - detail = content["artist"].toObject()["name"].toString(); - } else if (type == QStringLiteral("tracks")) { - badge = QStringLiteral("T"); - badgeColor = QColor(QStringLiteral("#66BB66")); - name = content["title"].toString(); - detail = content["performer"].toObject()["name"].toString(); - } else { - continue; - } - - auto *item = new QTreeWidgetItem(m_popularResults, QStringList{badge, name, detail}); - item->setForeground(0, badgeColor); - item->setFont(0, badgeFont); - item->setTextAlignment(0, Qt::AlignCenter); - item->setData(0, TypeRole, type == QStringLiteral("artists") ? QStringLiteral("artist") - : type == QStringLiteral("albums") ? QStringLiteral("album") - : QStringLiteral("track")); - item->setData(0, JsonRole, content); - - if (type == QStringLiteral("artists")) { - item->setData(0, IdRole, static_cast(content["id"].toDouble())); - } else if (type == QStringLiteral("albums")) { - item->setData(1, IdRole, content["id"].toString()); - } else { - item->setData(0, IdRole, static_cast(content["id"].toDouble())); - } - } -} - void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int) { if (!item) return; @@ -226,10 +153,7 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int) if (type == QStringLiteral("track")) { emit trackPlayRequested(item->data(0, IdRole).toLongLong()); } else if (type == QStringLiteral("album")) { - // Album ID may be in col 0 or col 1 depending on which tree it came from - QString albumId = item->data(1, IdRole).toString(); - if (albumId.isEmpty()) albumId = item->data(0, IdRole).toString(); - emit albumSelected(albumId); + emit albumSelected(item->data(1, IdRole).toString()); } else if (type == QStringLiteral("artist")) { emit artistSelected(item->data(0, IdRole).toLongLong()); } @@ -344,90 +268,6 @@ void SearchTab::onAlbumContextMenu(const QPoint &pos) menu.exec(m_albumResults->viewport()->mapToGlobal(pos)); } -void SearchTab::onPopularContextMenu(const QPoint &pos) -{ - auto *item = m_popularResults->itemAt(pos); - if (!item) return; - - const QString type = item->data(0, TypeRole).toString(); - const QJsonObject json = item->data(0, JsonRole).toJsonObject(); - QMenu menu(this); - - if (type == QStringLiteral("track")) { - const qint64 trackId = item->data(0, IdRole).toLongLong(); - if (trackId <= 0) return; - - auto *playNow = menu.addAction(tr("Play now")); - auto *playNext = menu.addAction(tr("Play next")); - auto *addQueue = menu.addAction(tr("Add to queue")); - menu.addSeparator(); - auto *addFav = menu.addAction(tr("Add to favorites")); - - const QString albumId = json["album"].toObject()["id"].toString(); - const qint64 artistId = static_cast(json["performer"].toObject()["id"].toDouble()); - const QString albumTitle = json["album"].toObject()["title"].toString(); - const QString artistName = json["performer"].toObject()["name"].toString(); - - menu.addSeparator(); - if (!albumId.isEmpty()) { - auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&")))); - connect(openAlbum, &QAction::triggered, this, [this, albumId] { emit albumSelected(albumId); }); - } - if (artistId > 0) { - auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&")))); - connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); }); - } - - if (!m_userPlaylists.isEmpty()) { - menu.addSeparator(); - auto *plMenu = menu.addMenu(tr("Add to playlist")); - for (const auto &pl : m_userPlaylists) { - auto *act = plMenu->addAction(pl.second); - connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] { - emit addToPlaylistRequested(trackId, plId); - }); - } - } - - menu.addSeparator(); - auto *info = menu.addAction(tr("Track info...")); - - connect(playNow, &QAction::triggered, this, [this, trackId] { emit trackPlayRequested(trackId); }); - connect(playNext, &QAction::triggered, this, [this, json] { m_queue->playNext(json); }); - connect(addQueue, &QAction::triggered, this, [this, json] { m_queue->addToQueue(json); }); - connect(addFav, &QAction::triggered, this, [this, trackId] { m_backend->addFavTrack(trackId); }); - connect(info, &QAction::triggered, this, [this, json] { showTrackInfo(json); }); - - } else if (type == QStringLiteral("album")) { - const QString albumId = item->data(1, IdRole).toString(); - if (albumId.isEmpty()) return; - - auto *openAlbum = menu.addAction(tr("Open album")); - auto *addFav = menu.addAction(tr("Add to favorites")); - - const qint64 artistId = static_cast(json["artist"].toObject()["id"].toDouble()); - const QString artistName = json["artist"].toObject()["name"].toString(); - if (artistId > 0) { - menu.addSeparator(); - auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&")))); - connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); }); - } - - connect(openAlbum, &QAction::triggered, this, [this, albumId] { emit albumSelected(albumId); }); - connect(addFav, &QAction::triggered, this, [this, albumId] { m_backend->addFavAlbum(albumId); }); - - } else if (type == QStringLiteral("artist")) { - const qint64 artistId = item->data(0, IdRole).toLongLong(); - if (artistId <= 0) return; - - auto *openArtist = menu.addAction(tr("Go to artist")); - connect(openArtist, &QAction::triggered, this, [this, artistId] { emit artistSelected(artistId); }); - } - - if (!menu.isEmpty()) - menu.exec(m_popularResults->viewport()->mapToGlobal(pos)); -} - void SearchTab::showTrackInfo(const QJsonObject &track) { TrackInfoDialog::show(track, this); diff --git a/src/view/sidepanel/view.hpp b/src/view/sidepanel/view.hpp index cf9dbd4..b19c8c3 100644 --- a/src/view/sidepanel/view.hpp +++ b/src/view/sidepanel/view.hpp @@ -30,7 +30,6 @@ namespace SidePanel private slots: void onSearchResult(const QJsonObject &result); - void onMostPopularResult(const QJsonObject &result); void onSearchSubmit(); void onItemDoubleClicked(QTreeWidgetItem *item, int column); @@ -39,7 +38,6 @@ namespace SidePanel PlayQueue *m_queue = nullptr; QLineEdit *m_searchBox = nullptr; QTabWidget *m_resultTabs = nullptr; - QTreeWidget *m_popularResults = nullptr; QTreeWidget *m_trackResults = nullptr; QTreeWidget *m_albumResults = nullptr; QTreeWidget *m_artistResults = nullptr; @@ -47,7 +45,6 @@ namespace SidePanel void onTrackContextMenu(const QPoint &pos); void onAlbumContextMenu(const QPoint &pos); - void onPopularContextMenu(const QPoint &pos); void showTrackInfo(const QJsonObject &track); };