SwiftSonic

July 20, 2026 · View on GitHub

CI Swift 6 SPM compatible GitHub release Platforms License: MIT

A modern, Swift-native client for the Subsonic and OpenSubsonic APIs.

import SwiftSonic

let client = SwiftSonicClient(
    serverURL: URL(string: "https://music.example.com")!,
    username: "alice",
    password: "secret"
)
let artists = try await client.getArtists()

That's it. No setup, no singleton, no global state.


Why SwiftSonic?

Every existing Swift Subsonic client is either abandoned, built on Alamofire, or missing OpenSubsonic support entirely. SwiftSonic fills that gap:

SwiftSonicSubsonicKitSubSonicAPI
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/CassetteLab/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

// Lazy — fetches once, caches for all subsequent calls
let caps = try await client.loadCapabilities()
print("Server: \(caps.serverType ?? "unknown") \(caps.serverVersion ?? "")")
print("OpenSubsonic: \(caps.isOpenSubsonic)")

// String overload
if caps.supports("songLyrics") { … }

// Typed KnownExtension overload (compile-time safe)
if caps.supports(.songLyrics) { … }
if caps.supports(.apiKeyAuthentication) { … }

// Force a fresh fetch (e.g. after re-auth)
let refreshed = try await client.refreshCapabilities()

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()
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
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)

// Replace mode — reorder or overwrite an existing playlist's tracks atomically
try await client.createPlaylist(playlistId: "42", songIds: ["303", "101", "202"])

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 (multiple IDs)
try await client.star(songIds: ["101", "201"], albumIds: ["10"])
try await client.unstar(songIds: ["101"])

// Single-item convenience overloads
try await client.star(songId: "101")
try await client.star(albumId: "10")
try await client.unstar(artistId: "5")

// 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")                    // completed

Error 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
}

SwiftSonicError also exposes convenience helpers for common checks:

HelperReturns true when…
isTransienterror is safe to retry (timeouts, 5xx, rate limits)
isAuthenticationFailureauth failed (wrong credentials, 401, 403)
isDNSFailureDNS resolution failed (.cannotFindHost, .dnsLookupFailed)
isCertificateErrorTLS certificate validation failed (server or client cert)
suggestedRetryDelay(non-nil) Retry-After seconds from a rate-limit 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

EndpointSwift API
pingping()
getLicensegetLicense()
getOpenSubsonicExtensionsgetOpenSubsonicExtensions() / fetchCapabilities() / loadCapabilities() / refreshCapabilities()

Browsing (ID3)

EndpointSwift API
getMusicFoldersgetMusicFolders()
getArtistsgetArtists(musicFolderId:)
getArtistgetArtist(id:)
getAlbumgetAlbum(id:)
getSonggetSong(id:)
getGenresgetGenres()
getIndexesgetIndexes(musicFolderId:ifModifiedSince:)
getMusicDirectorygetMusicDirectory(id:)
getArtistInfo2getArtistInfo2(id:count:includeNotPresent:)
getAlbumInfo2getAlbumInfo2(id:)

Browsing (folder-based)

EndpointSwift API
getArtistInfogetArtistInfo(id:count:includeNotPresent:)
getAlbumInfogetAlbumInfo(id:)

Lists (ID3)

EndpointSwift API
getAlbumList2getAlbumList2(type:size:offset:…)
getRandomSongsgetRandomSongs(size:genre:fromYear:toYear:musicFolderId:)
getSongsByGenregetSongsByGenre(_:count:offset:musicFolderId:)
getStarred2getStarred2(musicFolderId:)

Lists (folder-based)

EndpointSwift APINote
getAlbumListgetAlbumList(type:size:offset:…)Prefer getAlbumList2 for ID3 browsing
getStarredgetStarred(musicFolderId:)Prefer getStarred2 for ID3 browsing

Search

EndpointSwift APINote
search3search3(_:artistCount:albumCount:songCount:musicFolderId:)
search2search2(_:artistCount:albumCount:songCount:musicFolderId:)Prefer search3 for ID3 browsing

Discovery

EndpointSwift API
getSimilarSongsgetSimilarSongs(id:count:)
getSimilarSongs2getSimilarSongs2(id:count:)
getTopSongsgetTopSongs(artist:count:)

Playlists

EndpointSwift API
getPlaylistsgetPlaylists(username:)
getPlaylistgetPlaylist(id:)
createPlaylistcreatePlaylist(name:playlistId:songIds:)
updatePlaylistupdatePlaylist(id:name:comment:isPublic:songIdsToAdd:songIndexesToRemove:)
deletePlaylistdeletePlaylist(id:)

Media URLs (nonisolated, no await needed)

EndpointSwift API
streamstreamURL(id:maxBitRate:format:timeOffset:size:estimateContentLength:converted:)
downloaddownloadURL(id:)
getCoverArtcoverArtURL(id:size:)
hlshlsURL(id:bitRate:audioTrack:)
getAvataravatarURL(username:)

Annotations

EndpointSwift API
starstar(songIds:albumIds:artistIds:), star(songId:), star(albumId:), star(artistId:)
unstarunstar(songIds:albumIds:artistIds:), unstar(songId:), unstar(albumId:), unstar(artistId:)
setRatingsetRating(id:rating:)
scrobblescrobble(id:time:submission:)

Now Playing

EndpointSwift API
getNowPlayinggetNowPlaying()

Chat

EndpointSwift API
getChatMessagesgetChatMessages(since:)
addChatMessageaddChatMessage(_:)

Lyrics

EndpointSwift APINotes
getLyricsgetLyrics(artist:title:)Legacy Subsonic
getLyricsBySongIdgetLyricsBySongId(id:)OpenSubsonic songLyrics extension
// Legacy plain-text lyrics
if let lyrics = try await client.getLyrics(artist: "Nine Inch Nails", title: "Hurt") {
    print(lyrics.value ?? "")
}

// OpenSubsonic structured lyrics (synced + multi-language)
let list = try await client.getLyricsBySongId(id: song.id)
for set in list.structuredLyrics {
    print("\(set.lang) synced=\(set.synced)")
    for line in set.line {
        if let ms = line.start {
            print("[\(ms)ms] \(line.value)")
        } else {
            print(line.value)
        }
    }
}

User management

EndpointSwift API
getUsergetUser(username:)
getUsersgetUsers()
createUsercreateUser(_:)
updateUserupdateUser(_:)
deleteUserdeleteUser(username:)
changePasswordchangePassword(username:newPassword:)

Bookmarks

EndpointSwift API
getBookmarksgetBookmarks()
createBookmarkcreateBookmark(songId:position:comment:)
deleteBookmarkdeleteBookmark(songId:)

Play Queue

EndpointSwift API
getPlayQueuegetPlayQueue()
savePlayQueuesavePlayQueue(ids:current:position:)

Shares

EndpointSwift API
getSharesgetShares()
createSharecreateShare(ids:description:expires:)
updateShareupdateShare(id:description:expires:)
deleteSharedeleteShare(id:)

Podcasts

EndpointSwift API
getPodcastsgetPodcasts(id:includeEpisodes:)
getNewestPodcastsgetNewestPodcasts(count:)
refreshPodcastsrefreshPodcasts()
createPodcastChannelcreatePodcastChannel(url:)
deletePodcastChanneldeletePodcastChannel(id:)
downloadPodcastEpisodedownloadPodcastEpisode(id:)
deletePodcastEpisodedeletePodcastEpisode(id:)

Jukebox

EndpointSwift API
jukeboxControljukeboxGet(), jukeboxStatus(), jukeboxStart(), jukeboxStop(), jukeboxSkip(index:offset:), jukeboxAdd(ids:), jukeboxSet(ids:), jukeboxRemove(index:), jukeboxClear(), jukeboxShuffle(), jukeboxSetGain(_:)

Internet Radio

EndpointSwift API
getInternetRadioStationsgetInternetRadioStations()
createInternetRadioStationcreateInternetRadioStation(streamURL:name:homepageURL:)
updateInternetRadioStationupdateInternetRadioStation(id:streamURL:name:homepageURL:)
deleteInternetRadioStationdeleteInternetRadioStation(id:)

Scan

EndpointSwift API
getScanStatusgetScanStatus()
startScanstartScan()

Design principles

  • Thread-safe by constructionSwiftSonicClient is a Swift actor
  • Zero dependencies — only Foundation and CryptoKit
  • Sendable everywhere — all public types conform to Sendable, zero warnings in strict concurrency
  • Injectable transport — swap out URLSession for testing, proxying, or cert pinning
  • No UI couplingData and URL only, never UIImage or SwiftUI.Image
  • Resilient by default — 3-attempt exponential back-off retry, configurable via RetryPolicy
  • ObservablelogSubsystem: for os.Logger output; metricsCollector: for custom metrics

Documentation

Design decisions, the internal security audit, and cross-cutting technical notes live in the CassetteLab knowledge vault — an Obsidian vault versioned with Git, shared across the whole ecosystem.

Anything that explains why a choice was made lives there rather than in this repo. README.md, CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, and LICENSE stay here.

The vault is private to the organisation — open an issue if you need access.


Contributing

See CONTRIBUTING.md.


License

MIT — see LICENSE.