643 lines
24 KiB
Swift
643 lines
24 KiB
Swift
//
|
||
// MAService.swift
|
||
// Mobile Music Assistant
|
||
//
|
||
// Created by Sven Hanold on 26.03.26.
|
||
//
|
||
|
||
import Foundation
|
||
import OSLog
|
||
|
||
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Service")
|
||
|
||
/// High-level service for Music Assistant API
|
||
@Observable
|
||
final class MAService {
|
||
// MARK: - Properties
|
||
|
||
let authManager: MAAuthManager
|
||
let webSocketClient: MAWebSocketClient
|
||
let playerManager: MAPlayerManager
|
||
let libraryManager: MALibraryManager
|
||
|
||
private(set) var isConnected = false
|
||
|
||
// Cache for artist/album detail responses — keyed by URI, only stored when description is present
|
||
private var artistDetailCache: [String: MAArtist] = [:]
|
||
private var albumDetailCache: [String: MAAlbum] = [:]
|
||
|
||
// MARK: - Initialization
|
||
|
||
init() {
|
||
// Initialize simple properties first
|
||
self.authManager = MAAuthManager()
|
||
self.webSocketClient = MAWebSocketClient()
|
||
|
||
// Create a temporary service reference
|
||
let tempPlayerManager = MAPlayerManager(service: nil)
|
||
let tempLibraryManager = MALibraryManager(service: nil)
|
||
|
||
self.playerManager = tempPlayerManager
|
||
self.libraryManager = tempLibraryManager
|
||
|
||
// Now set the service reference
|
||
tempPlayerManager.setService(self)
|
||
tempLibraryManager.setService(self)
|
||
}
|
||
|
||
// MARK: - Connection
|
||
|
||
/// Connect to Music Assistant server using saved credentials
|
||
func connectWithSavedCredentials() async throws {
|
||
guard authManager.isAuthenticated,
|
||
let serverURL = authManager.serverURL,
|
||
let token = authManager.currentToken else {
|
||
throw MAAuthManager.AuthError.noStoredCredentials
|
||
}
|
||
|
||
try await connect(serverURL: serverURL, token: token)
|
||
}
|
||
|
||
/// Connect to server with explicit credentials
|
||
func connect(serverURL: URL, token: String) async throws {
|
||
logger.info("Connecting to Music Assistant")
|
||
try await webSocketClient.connect(serverURL: serverURL, authToken: token)
|
||
isConnected = true
|
||
}
|
||
|
||
/// Disconnect from server
|
||
func disconnect() {
|
||
logger.info("Disconnecting from Music Assistant")
|
||
webSocketClient.disconnect()
|
||
isConnected = false
|
||
}
|
||
|
||
// MARK: - Players
|
||
|
||
/// Get all players
|
||
func getPlayers() async throws -> [MAPlayer] {
|
||
logger.debug("Fetching players")
|
||
return try await webSocketClient.sendCommand(
|
||
"players/all",
|
||
resultType: [MAPlayer].self
|
||
)
|
||
}
|
||
|
||
/// Play on a player
|
||
func play(playerId: String) async throws {
|
||
logger.debug("Playing on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/play",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Pause a player
|
||
func pause(playerId: String) async throws {
|
||
logger.debug("Pausing player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/pause",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Stop a player
|
||
func stop(playerId: String) async throws {
|
||
logger.debug("Stopping player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/stop",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Next track
|
||
func nextTrack(playerId: String) async throws {
|
||
logger.debug("Next track on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/next",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Previous track
|
||
func previousTrack(playerId: String) async throws {
|
||
logger.debug("Previous track on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/previous",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Group a player under a target leader (MA 2.x: players/cmd/group)
|
||
func syncPlayer(playerId: String, targetPlayerId: String) async throws {
|
||
logger.debug("Grouping player \(playerId) under \(targetPlayerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/group",
|
||
args: ["player_id": playerId, "target_player": targetPlayerId]
|
||
)
|
||
}
|
||
|
||
/// Remove a player from its group (MA 2.x: players/cmd/ungroup)
|
||
func unsyncPlayer(playerId: String) async throws {
|
||
logger.debug("Ungrouping player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/ungroup",
|
||
args: ["player_id": playerId]
|
||
)
|
||
}
|
||
|
||
/// Seek to a position in the current track (seconds)
|
||
func seek(playerId: String, position: Double) async throws {
|
||
let seconds = Int(max(0, position))
|
||
logger.debug("Seeking to \(seconds)s on player \(playerId)")
|
||
let response = try await webSocketClient.sendCommand(
|
||
"players/cmd/seek",
|
||
args: [
|
||
"player_id": playerId,
|
||
"position": seconds
|
||
]
|
||
)
|
||
logger.debug("Seek response: errorCode=\(String(describing: response.errorCode)) details=\(String(describing: response.details))")
|
||
}
|
||
|
||
/// Set group master volume (0-100) — scales all members proportionally
|
||
func setGroupVolume(playerId: String, level: Int) async throws {
|
||
let clampedLevel = max(0, min(100, level))
|
||
logger.debug("Setting group volume to \(clampedLevel) on leader \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/group_volume",
|
||
args: ["player_id": playerId, "volume_level": clampedLevel]
|
||
)
|
||
}
|
||
|
||
/// Set volume (0-100)
|
||
func setVolume(playerId: String, level: Int) async throws {
|
||
let clampedLevel = max(0, min(100, level))
|
||
logger.debug("Setting volume to \(clampedLevel) on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"players/cmd/volume_set",
|
||
args: [
|
||
"player_id": playerId,
|
||
"volume_level": clampedLevel
|
||
]
|
||
)
|
||
}
|
||
|
||
// MARK: - Queue
|
||
|
||
/// Get the queue state for a player (includes current_item)
|
||
func getPlayerQueue(playerId: String) async throws -> MAPlayerQueue {
|
||
logger.debug("Fetching queue state for player \(playerId)")
|
||
return try await webSocketClient.sendCommand(
|
||
"player_queues/get",
|
||
args: ["queue_id": playerId],
|
||
resultType: MAPlayerQueue.self
|
||
)
|
||
}
|
||
|
||
/// Get player queue items list
|
||
func getQueue(playerId: String) async throws -> [MAQueueItem] {
|
||
logger.debug("Fetching queue for player \(playerId)")
|
||
return try await webSocketClient.sendCommand(
|
||
"player_queues/items",
|
||
args: ["queue_id": playerId],
|
||
resultType: [MAQueueItem].self
|
||
)
|
||
}
|
||
|
||
/// Play media item
|
||
func playMedia(playerId: String, uri: String) async throws {
|
||
logger.debug("Playing media \(uri) on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/play_media",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"media": [uri]
|
||
]
|
||
)
|
||
}
|
||
|
||
/// Play a list of media items (replaces current queue)
|
||
func playMedia(playerId: String, uris: [String]) async throws {
|
||
logger.debug("Playing \(uris.count) tracks on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/play_media",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"media": uris
|
||
]
|
||
)
|
||
}
|
||
|
||
/// Add media item to the end of a player's queue
|
||
func enqueueMedia(playerId: String, uri: String) async throws {
|
||
logger.debug("Enqueuing media \(uri) on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/play_media",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"media": [uri],
|
||
"option": "add"
|
||
]
|
||
)
|
||
}
|
||
|
||
/// Play from queue index
|
||
func playIndex(playerId: String, index: Int) async throws {
|
||
logger.debug("Playing index \(index) on player \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/play_index",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"index": index
|
||
]
|
||
)
|
||
}
|
||
|
||
/// Move queue item
|
||
func moveQueueItem(playerId: String, queueItemId: String, posShift: Int) async throws {
|
||
logger.debug("Moving queue item \(queueItemId) by \(posShift) positions")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/move_item",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"queue_item_id": queueItemId,
|
||
"pos_shift": posShift
|
||
]
|
||
)
|
||
}
|
||
|
||
func setQueueShuffle(playerId: String, enabled: Bool) async throws {
|
||
logger.debug("Setting shuffle \(enabled) on queue \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/shuffle",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"shuffle_enabled": enabled
|
||
]
|
||
)
|
||
}
|
||
|
||
func setQueueRepeatMode(playerId: String, mode: RepeatMode) async throws {
|
||
logger.debug("Setting repeat mode \(mode.rawValue) on queue \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/repeat",
|
||
args: [
|
||
"queue_id": playerId,
|
||
"repeat_mode": mode.rawValue
|
||
]
|
||
)
|
||
}
|
||
|
||
func clearQueue(playerId: String) async throws {
|
||
logger.debug("Clearing queue \(playerId)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"player_queues/clear",
|
||
args: ["queue_id": playerId]
|
||
)
|
||
}
|
||
|
||
// MARK: - Library
|
||
|
||
/// Get artists (with pagination)
|
||
func getArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] {
|
||
logger.debug("Fetching artists (limit: \(limit), offset: \(offset))")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/artists/library_items",
|
||
args: [
|
||
"limit": limit,
|
||
"offset": offset
|
||
],
|
||
resultType: [MAArtist].self
|
||
)
|
||
}
|
||
|
||
/// Get album artists (with pagination) — only artists that appear as primary album artists
|
||
func getAlbumArtists(limit: Int = 50, offset: Int = 0) async throws -> [MAArtist] {
|
||
logger.debug("Fetching album artists (limit: \(limit), offset: \(offset))")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/artists/library_items",
|
||
args: [
|
||
"limit": limit,
|
||
"offset": offset,
|
||
"album_artists_only": true
|
||
],
|
||
resultType: [MAArtist].self
|
||
)
|
||
}
|
||
|
||
/// Get albums (with pagination)
|
||
func getAlbums(limit: Int = 50, offset: Int = 0) async throws -> [MAAlbum] {
|
||
logger.debug("Fetching albums (limit: \(limit), offset: \(offset))")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/albums/library_items",
|
||
args: [
|
||
"limit": limit,
|
||
"offset": offset
|
||
],
|
||
resultType: [MAAlbum].self
|
||
)
|
||
}
|
||
|
||
/// Get tracks from the library (with optional pagination and favorite filter)
|
||
func getTracks(favorite: Bool? = nil, limit: Int = 50, offset: Int = 0) async throws -> [MAMediaItem] {
|
||
logger.debug("Fetching tracks (limit: \(limit), offset: \(offset), favorite: \(String(describing: favorite)))")
|
||
var args: [String: Any] = ["limit": limit, "offset": offset]
|
||
if let favorite { args["favorite"] = favorite }
|
||
return try await webSocketClient.sendCommand(
|
||
"music/tracks/library_items",
|
||
args: args,
|
||
resultType: [MAMediaItem].self
|
||
)
|
||
}
|
||
|
||
/// Get radio stations
|
||
func getRadios() async throws -> [MAMediaItem] {
|
||
logger.debug("Fetching radios")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/radios/library_items",
|
||
resultType: [MAMediaItem].self
|
||
)
|
||
}
|
||
|
||
/// Get playlists
|
||
func getPlaylists() async throws -> [MAPlaylist] {
|
||
logger.debug("Fetching playlists")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/playlists/library_items",
|
||
resultType: [MAPlaylist].self
|
||
)
|
||
}
|
||
|
||
/// Get all podcasts in the library
|
||
func getPodcasts() async throws -> [MAPodcast] {
|
||
logger.debug("Fetching podcasts")
|
||
return try await webSocketClient.sendCommand(
|
||
"music/podcasts/library_items",
|
||
resultType: [MAPodcast].self
|
||
)
|
||
}
|
||
|
||
/// Get all episodes for a podcast
|
||
func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] {
|
||
logger.debug("Fetching episodes for podcast \(podcastUri)")
|
||
guard let (provider, itemId) = parseMAUri(podcastUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid podcast URI: \(podcastUri)")
|
||
}
|
||
return try await webSocketClient.sendCommand(
|
||
"music/podcasts/podcast_episodes",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: [MAMediaItem].self
|
||
)
|
||
}
|
||
|
||
/// Get full artist details (includes biography in metadata.description).
|
||
/// Results are cached in memory once biography data is available, so repeated
|
||
/// navigation is instant. Skips the cache if the previous fetch had no biography,
|
||
/// allowing the server time to enrich the metadata.
|
||
func getArtistDetail(artistUri: String) async throws -> MAArtist {
|
||
if let cached = artistDetailCache[artistUri],
|
||
cached.metadata?.description?.isEmpty == false {
|
||
return cached
|
||
}
|
||
logger.debug("Fetching artist detail for \(artistUri)")
|
||
guard let (provider, itemId) = parseMAUri(artistUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)")
|
||
}
|
||
let detail = try await webSocketClient.sendCommand(
|
||
"music/artists/get",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: MAArtist.self
|
||
)
|
||
artistDetailCache[artistUri] = detail
|
||
return detail
|
||
}
|
||
|
||
/// Get full album details (includes description in metadata.description).
|
||
/// Results are cached once description data is available.
|
||
func getAlbumDetail(albumUri: String) async throws -> MAAlbum {
|
||
if let cached = albumDetailCache[albumUri],
|
||
cached.metadata?.description?.isEmpty == false {
|
||
return cached
|
||
}
|
||
logger.debug("Fetching album detail for \(albumUri)")
|
||
guard let (provider, itemId) = parseMAUri(albumUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)")
|
||
}
|
||
let detail = try await webSocketClient.sendCommand(
|
||
"music/albums/get",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: MAAlbum.self
|
||
)
|
||
albumDetailCache[albumUri] = detail
|
||
return detail
|
||
}
|
||
|
||
/// Get albums for an artist
|
||
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
|
||
logger.debug("Fetching albums for artist \(artistUri)")
|
||
guard let (provider, itemId) = parseMAUri(artistUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid artist URI: \(artistUri)")
|
||
}
|
||
return try await webSocketClient.sendCommand(
|
||
"music/artists/artist_albums",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: [MAAlbum].self
|
||
)
|
||
}
|
||
|
||
/// Get playlist tracks
|
||
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
|
||
logger.debug("Fetching tracks for playlist \(playlistUri)")
|
||
guard let (provider, itemId) = parseMAUri(playlistUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid playlist URI: \(playlistUri)")
|
||
}
|
||
return try await webSocketClient.sendCommand(
|
||
"music/playlists/playlist_tracks",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: [MAMediaItem].self
|
||
)
|
||
}
|
||
|
||
/// Get album tracks
|
||
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
||
logger.debug("Fetching tracks for album \(albumUri)")
|
||
guard let (provider, itemId) = parseMAUri(albumUri) else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid album URI: \(albumUri)")
|
||
}
|
||
return try await webSocketClient.sendCommand(
|
||
"music/albums/album_tracks",
|
||
args: [
|
||
"item_id": itemId,
|
||
"provider_instance_id_or_domain": provider
|
||
],
|
||
resultType: [MAMediaItem].self
|
||
)
|
||
}
|
||
|
||
// MARK: - Favorites
|
||
|
||
/// Add an item to favorites by URI
|
||
func addFavorite(uri: String) async throws {
|
||
logger.debug("Adding favorite: \(uri)")
|
||
_ = try await webSocketClient.sendCommand(
|
||
"music/favorites/add_item",
|
||
args: ["item": uri]
|
||
)
|
||
}
|
||
|
||
/// Remove an item from favorites.
|
||
/// The server expects media_type (e.g. "artist") and library_item_id (e.g. "123").
|
||
/// These are extracted from the URI format: library://artist/123
|
||
func removeFavorite(uri: String) async throws {
|
||
logger.debug("Removing favorite: \(uri)")
|
||
guard let url = URL(string: uri),
|
||
let host = url.host else {
|
||
throw MAWebSocketClient.ClientError.serverError("Invalid URI for favorite removal: \(uri)")
|
||
}
|
||
let itemId = url.path.isEmpty ? host : String(url.path.dropFirst())
|
||
_ = try await webSocketClient.sendCommand(
|
||
"music/favorites/remove_item",
|
||
args: [
|
||
"media_type": host,
|
||
"library_item_id": itemId
|
||
]
|
||
)
|
||
}
|
||
|
||
/// Search library
|
||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||
logger.debug("🔍 Searching for '\(query)'")
|
||
|
||
var args: [String: Any] = ["search_query": query]
|
||
if let mediaTypes {
|
||
args["media_types"] = mediaTypes.map { $0.rawValue }
|
||
}
|
||
|
||
// Try to get the response
|
||
let response = try await webSocketClient.sendCommand("music/search", args: args)
|
||
|
||
guard let result = response.result else {
|
||
logger.error("❌ Search returned no result")
|
||
return []
|
||
}
|
||
|
||
// Log raw result
|
||
if let jsonData = try? JSONEncoder().encode(result),
|
||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||
logger.debug("📦 Raw search response (first 500 chars): \(String(jsonString.prefix(500)))")
|
||
}
|
||
|
||
do {
|
||
// Music Assistant returns search results categorized by type
|
||
struct SearchResults: Decodable {
|
||
let albums: [MAMediaItem]?
|
||
let tracks: [MAMediaItem]?
|
||
let artists: [MAMediaItem]?
|
||
let playlists: [MAMediaItem]?
|
||
let radios: [MAMediaItem]?
|
||
let podcasts: [MAMediaItem]?
|
||
}
|
||
|
||
let searchResults = try result.decode(as: SearchResults.self)
|
||
|
||
// Combine all results into a single array
|
||
var allItems: [MAMediaItem] = []
|
||
if let albums = searchResults.albums { allItems.append(contentsOf: albums) }
|
||
if let tracks = searchResults.tracks { allItems.append(contentsOf: tracks) }
|
||
if let artists = searchResults.artists { allItems.append(contentsOf: artists) }
|
||
if let playlists = searchResults.playlists { allItems.append(contentsOf: playlists) }
|
||
if let radios = searchResults.radios { allItems.append(contentsOf: radios) }
|
||
if let podcasts = searchResults.podcasts { allItems.append(contentsOf: podcasts) }
|
||
|
||
logger.info("✅ Decoded \(allItems.count) search results (albums: \(searchResults.albums?.count ?? 0), tracks: \(searchResults.tracks?.count ?? 0), artists: \(searchResults.artists?.count ?? 0), radios: \(searchResults.radios?.count ?? 0))")
|
||
return allItems
|
||
|
||
} catch let error {
|
||
logger.error("❌ Failed to decode search results: \(error)")
|
||
|
||
// Log the actual structure for debugging
|
||
if let jsonData = try? JSONEncoder().encode(result),
|
||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||
logger.error("📦 Raw search response (first 1000 chars): \(String(jsonString.prefix(1000)))")
|
||
}
|
||
|
||
// Return empty array instead of throwing
|
||
return []
|
||
}
|
||
}
|
||
|
||
/// Parse a Music Assistant URI into (provider, itemId)
|
||
/// MA URIs follow the format: scheme://media_type/item_id
|
||
///
|
||
/// Uses manual string parsing because some schemes contain underscores
|
||
/// (e.g. "apple_music") which are invalid per RFC 2396, causing Swift's
|
||
/// URL(string:) initialiser to return nil for those URIs.
|
||
private func parseMAUri(_ uri: String) -> (provider: String, itemId: String)? {
|
||
guard let sepRange = uri.range(of: "://") else { return nil }
|
||
let provider = String(uri[uri.startIndex..<sepRange.lowerBound])
|
||
guard !provider.isEmpty else { return nil }
|
||
|
||
let remainder = String(uri[sepRange.upperBound...])
|
||
guard !remainder.isEmpty else { return nil }
|
||
|
||
// remainder is "media_type/item_id" or just "item_id"
|
||
if let slashIdx = remainder.firstIndex(of: "/") {
|
||
let itemId = String(remainder[remainder.index(after: slashIdx)...])
|
||
guard !itemId.isEmpty else { return nil }
|
||
return (provider, itemId)
|
||
} else {
|
||
return (provider, remainder)
|
||
}
|
||
}
|
||
|
||
// MARK: - Image Proxy
|
||
|
||
/// Build URL for the MA image proxy.
|
||
///
|
||
/// MA uses `/imageproxy` (not `/api/image_proxy`) and requires the `path` value to be
|
||
/// double-URL-encoded — equivalent to the frontend's
|
||
/// `encodeURIComponent(encodeURIComponent(img.path))`.
|
||
///
|
||
/// - Parameters:
|
||
/// - path: The image path or remote URL from `MediaItemImage.path`. Nil returns nil.
|
||
/// - provider: The provider key from `MediaItemImage.provider`.
|
||
/// - size: Desired pixel size (width and height).
|
||
func imageProxyURL(path: String?, provider: String? = nil, size: Int = 256) -> URL? {
|
||
guard let path, !path.isEmpty, let serverURL = authManager.serverURL else { return nil }
|
||
|
||
// Replicate JS encodeURIComponent (encodes everything except A–Z a–z 0–9 - _ . ! ~ * ' ( ))
|
||
let uriAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.!~*'()"))
|
||
let once = path.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? path
|
||
let twice = once.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? once
|
||
|
||
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
|
||
components.path = "/imageproxy"
|
||
|
||
// Build the query manually so URLComponents doesn't re-encode the already-encoded value
|
||
var queryParts = ["path=\(twice)", "size=\(size)"]
|
||
if let provider, !provider.isEmpty {
|
||
let encProvider = provider.addingPercentEncoding(withAllowedCharacters: uriAllowed) ?? provider
|
||
queryParts.append("provider=\(encProvider)")
|
||
}
|
||
components.percentEncodedQuery = queryParts.joined(separator: "&")
|
||
return components.url
|
||
}
|
||
|
||
}
|