Contents

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

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

Contributing

See CONTRIBUTING.md.


License

MIT — see LICENSE.

Package Metadata

Repository: mathieudubart/swiftsonic

Default branch: main

README: README.md