mathieudubart/swiftsonic
A modern, Swift-native client for the [Subsonic](http://www.subsonic.org/pages/api.jsp) and [OpenSubsonic](https://opensubsonic.netlify.app/) APIs.
Why SwiftSonic?
Every existing Swift Subsonic client is either abandoned, built on Alamofire, or missing OpenSubsonic support entirely. SwiftSonic fills that gap:
| | SwiftSonic | SubsonicKit | SubSonicAPI | |---|---|---|---| | Swift 6 strict concurrency | ✅ | ❌ | ❌ | | OpenSubsonic extensions | ✅ | ❌ | ❌ | | Zero dependencies | ✅ | ❌ (Alamofire) | ❌ | | async/await native | ✅ | ✅ | ❌ | | Typed error codes | ✅ | ❌ | ❌ | | Injectable transport | ✅ | ❌ | ❌ | | Actively maintained | ✅ | ⚠️ | ❌ | | Automatic retry | ✅ | ❌ | ❌ | | Observability hook | ✅ | ❌ | ❌ |
Requirements
- iOS 16+ / macOS 13+ / tvOS 16+ / watchOS 9+ / visionOS 1+
- Swift 5.9+
- Xcode 15+
Installation
Swift Package Manager
Add SwiftSonic to your Package.swift:
dependencies: [
.package(url: "https://github.com/MathieuDubart/swiftsonic.git", from: "0.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [.product(name: "SwiftSonic", package: "swiftsonic")]
)
]Or in Xcode: File → Add Package Dependencies → paste the repo URL.
Usage
Basic setup
import SwiftSonic
// Standard token auth (most servers)
let client = SwiftSonicClient(
serverURL: URL(string: "https://music.example.com")!,
username: "alice",
password: "secret"
)
// API key auth (OpenSubsonic servers)
let client = SwiftSonicClient(
configuration: ServerConfiguration(
serverURL: URL(string: "https://music.example.com")!,
auth: .apiKey("my-api-key")
)
)Checking server capabilities
try await client.fetchCapabilities()
if let caps = client.serverCapabilities {
print("Server: \(caps.serverType ?? "unknown") \(caps.serverVersion ?? "")")
print("OpenSubsonic: \(caps.isOpenSubsonic)")
if caps.supports("songLyrics") {
// call OpenSubsonic-specific endpoints
}
}Browsing
// All artists, grouped by index letter
let indexes = try await client.getArtists()
for index in indexes {
for artist in index.artist {
print(artist.name)
}
}
// Music folders
let folders = try await client.getMusicFolders()Search
let results = try await client.search3("bohemian", songCount: 10)
print(results.song?.first?.title) // "Bohemian Rhapsody"
print(results.artist?.first?.name) // "Queen"Playlists
// List all playlists
let playlists = try await client.getPlaylists()
// Fetch a specific playlist with its tracks
let playlist = try await client.getPlaylist(id: "42")
for song in playlist.entry ?? [] {
print("\(song.title) — \(song.artist ?? "")")
}
// Create and modify
let newPlaylist = try await client.createPlaylist(name: "Road Trip", songIds: ["101", "202"])
try await client.updatePlaylist(id: newPlaylist.id, isPublic: true, songIdsToAdd: ["303"])
try await client.deletePlaylist(id: newPlaylist.id)Media URLs
Media URL methods are nonisolated — no await needed:
// Stream a song in AVPlayer
if let url = client.streamURL(id: "101", maxBitRate: 320, format: "mp3") {
let player = AVPlayer(url: url)
player.play()
}
// Cover art for AsyncImage
if let url = client.coverArtURL(id: "al-10", size: 300) {
AsyncImage(url: url)
}
// Download
let downloadLink = client.downloadURL(id: "101")Annotations
// Star songs and albums
try await client.star(songIds: ["101", "201"], albumIds: ["10"])
try await client.unstar(songIds: ["101"])
// Rate (1–5, or 0 to remove)
try await client.setRating(id: "101", rating: 5)
// Scrobble (now playing or completed play)
try await client.scrobble(id: "101", submission: false) // now playing
try await client.scrobble(id: "101") // completedError handling
do {
try await client.ping()
} catch SwiftSonicError.api(let error) {
switch error.code {
case .wrongCredentials:
// prompt re-auth
case .notFound:
// resource missing
default:
print("Server error \(error.code.rawValue): \(error.message)")
}
} catch SwiftSonicError.network(let urlError) {
// no connectivity
} catch SwiftSonicError.httpError(let statusCode, _) {
// non-2xx response
}Retry and resilience
SwiftSonicClient automatically retries transient failures (network errors, HTTP 5xx, HTTP 429) with exponential back-off. The default policy makes up to 3 attempts:
// Default: 3 attempts, ~0.5s → ~1s → ~2s (±20% jitter)
let client = SwiftSonicClient(configuration: config)
// Custom policy
let client = SwiftSonicClient(
configuration: config,
retryPolicy: RetryPolicy(maxAttempts: 5, baseDelay: 1.0)
)
// Disable retries entirely
let client = SwiftSonicClient(
configuration: config,
retryPolicy: .none
)Non-transient errors (authentication failures, 4xx, decoding errors) are never retried. A 429 response honours the Retry-After header when present.
Observability
Logging
Pass logSubsystem: to enable os.Logger output under the SwiftSonicClient category. The client logs every attempt, retry, success, and failure — visible in Console.app and Instruments.
let client = SwiftSonicClient(
configuration: config,
logSubsystem: "com.example.MyApp" // silent by default
)Metrics hook
Implement SwiftSonicMetricsCollector to integrate with your observability backend (Datadog, Sentry, custom analytics):
final class AppMetrics: SwiftSonicMetricsCollector, @unchecked Sendable {
func record(_ event: SwiftSonicRequestEvent) {
switch event {
case .succeeded(let endpoint, _, let duration):
Analytics.track("api_request", ["endpoint": endpoint, "duration": duration])
case .failed(let endpoint, _, let error, _):
Crashlytics.recordError(error, userInfo: ["endpoint": endpoint])
case .retryScheduled(let endpoint, let attempt, let delay):
print("[\(endpoint)] retry \(attempt + 1) in \(String(format: "%.2f", delay))s")
default:
break
}
}
}
let client = SwiftSonicClient(
configuration: config,
metricsCollector: AppMetrics()
)Custom transport (logging, cert pinning, proxies)
struct LoggingTransport: HTTPTransport {
let underlying: any HTTPTransport = URLSessionTransport()
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
print("→ \(request.url!)")
let result = try await underlying.data(for: request)
print("← \(result.1.statusCode)")
return result
}
}
let client = SwiftSonicClient(
configuration: config,
transport: LoggingTransport()
)Endpoint coverage
System
| Endpoint | Swift API | |---|---| | ping | ping() | | getLicense | getLicense() | | getOpenSubsonicExtensions | getOpenSubsonicExtensions() / fetchCapabilities() |
Browsing (ID3)
| Endpoint | Swift API | |---|---| | getMusicFolders | getMusicFolders() | | getArtists | getArtists(musicFolderId:) | | getArtist | getArtist(id:) | | getAlbum | getAlbum(id:) | | getSong | getSong(id:) | | getGenres | getGenres() | | getIndexes | getIndexes(musicFolderId:ifModifiedSince:) | | getMusicDirectory | getMusicDirectory(id:) | | getArtistInfo2 | getArtistInfo2(id:count:includeNotPresent:) | | getAlbumInfo2 | getAlbumInfo2(id:) |
Browsing (folder-based)
| Endpoint | Swift API | |---|---| | getArtistInfo | getArtistInfo(id:count:includeNotPresent:) | | getAlbumInfo | getAlbumInfo(id:) |
Lists (ID3)
| Endpoint | Swift API | |---|---| | getAlbumList2 | getAlbumList2(type:size:offset:…) | | getRandomSongs | getRandomSongs(size:genre:fromYear:toYear:musicFolderId:) | | getSongsByGenre | getSongsByGenre(_:count:offset:musicFolderId:) | | getStarred2 | getStarred2(musicFolderId:) |
Lists (folder-based)
| Endpoint | Swift API | Note | |---|---|---| | getAlbumList | getAlbumList(type:size:offset:…) | Prefer getAlbumList2 for ID3 browsing | | getStarred | getStarred(musicFolderId:) | Prefer getStarred2 for ID3 browsing |
Search
| Endpoint | Swift API | Note | |---|---|---| | search3 | search3(:artistCount:albumCount:songCount:musicFolderId:) | | | search2 | search2(:artistCount:albumCount:songCount:musicFolderId:) | Prefer search3 for ID3 browsing |
Discovery
| Endpoint | Swift API | |---|---| | getSimilarSongs | getSimilarSongs(id:count:) | | getSimilarSongs2 | getSimilarSongs2(id:count:) | | getTopSongs | getTopSongs(artist:count:) |
Playlists
| Endpoint | Swift API | |---|---| | getPlaylists | getPlaylists(username:) | | getPlaylist | getPlaylist(id:) | | createPlaylist | createPlaylist(name:songIds:) | | updatePlaylist | updatePlaylist(id:name:comment:isPublic:songIdsToAdd:songIndexesToRemove:) | | deletePlaylist | deletePlaylist(id:) |
Media URLs (nonisolated, no await needed)
| Endpoint | Swift API | |---|---| | stream | streamURL(id:maxBitRate:format:timeOffset:size:estimateContentLength:converted:) | | download | downloadURL(id:) | | getCoverArt | coverArtURL(id:size:) | | hls | hlsURL(id:bitRate:audioTrack:) | | getAvatar | avatarURL(username:) |
Annotations
| Endpoint | Swift API | |---|---| | star | star(songIds:albumIds:artistIds:) | | unstar | unstar(songIds:albumIds:artistIds:) | | setRating | setRating(id:rating:) | | scrobble | scrobble(id:time:submission:) |
Now Playing
| Endpoint | Swift API | |---|---| | getNowPlaying | getNowPlaying() |
Chat
| Endpoint | Swift API | |---|---| | getChatMessages | getChatMessages(since:) | | addChatMessage | addChatMessage(_:) |
Lyrics
| Endpoint | Swift API | |---|---| | getLyrics | getLyrics(artist:title:) |
User management
| Endpoint | Swift API | |---|---| | getUser | getUser(username:) | | getUsers | getUsers() | | createUser | createUser(:) | | updateUser | updateUser(:) | | deleteUser | deleteUser(username:) | | changePassword | changePassword(username:newPassword:) |
Bookmarks
| Endpoint | Swift API | |---|---| | getBookmarks | getBookmarks() | | createBookmark | createBookmark(songId:position:comment:) | | deleteBookmark | deleteBookmark(songId:) |
Play Queue
| Endpoint | Swift API | |---|---| | getPlayQueue | getPlayQueue() | | savePlayQueue | savePlayQueue(ids:current:position:) |
Shares
| Endpoint | Swift API | |---|---| | getShares | getShares() | | createShare | createShare(ids:description:expires:) | | updateShare | updateShare(id:description:expires:) | | deleteShare | deleteShare(id:) |
Podcasts
| Endpoint | Swift API | |---|---| | getPodcasts | getPodcasts(id:includeEpisodes:) | | getNewestPodcasts | getNewestPodcasts(count:) | | refreshPodcasts | refreshPodcasts() | | createPodcastChannel | createPodcastChannel(url:) | | deletePodcastChannel | deletePodcastChannel(id:) | | downloadPodcastEpisode | downloadPodcastEpisode(id:) | | deletePodcastEpisode | deletePodcastEpisode(id:) |
Jukebox
| Endpoint | Swift API | |---|---| | jukeboxControl | jukeboxGet(), jukeboxStatus(), jukeboxStart(), jukeboxStop(), jukeboxSkip(index:offset:), jukeboxAdd(ids:), jukeboxSet(ids:), jukeboxRemove(index:), jukeboxClear(), jukeboxShuffle(), jukeboxSetGain(_:) |
Internet Radio
| Endpoint | Swift API | |---|---| | getInternetRadioStations | getInternetRadioStations() | | createInternetRadioStation | createInternetRadioStation(streamURL:name:homepageURL:) | | updateInternetRadioStation | updateInternetRadioStation(id:streamURL:name:homepageURL:) | | deleteInternetRadioStation | deleteInternetRadioStation(id:) |
Scan
| Endpoint | Swift API | |---|---| | getScanStatus | getScanStatus() | | startScan | startScan() |
Design principles
- Thread-safe by construction —
SwiftSonicClientis a Swift actor - Zero dependencies — only
FoundationandCryptoKit - Sendable everywhere — all public types conform to
Sendable, zero warnings in strict concurrency - Injectable transport — swap out
URLSessionfor testing, proxying, or cert pinning - No UI coupling —
DataandURLonly, neverUIImageorSwiftUI.Image - Resilient by default — 3-attempt exponential back-off retry, configurable via
RetryPolicy - Observable —
logSubsystem:foros.Loggeroutput;metricsCollector:for custom metrics
Contributing
See CONTRIBUTING.md.
License
MIT — see LICENSE.
Package Metadata
Repository: mathieudubart/swiftsonic
Default branch: main
README: README.md