package cloud.alexandrevazquez.immichtv.data import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.util.concurrent.TimeUnit data class Album(val id: String, val name: String, val assetCount: Int) data class Asset( val id: String, val type: String, val width: Int, val height: Int, val fileName: String, val takenAt: String?, val city: String?, val country: String?, val durationMs: Long, ) { val isVideo: Boolean get() = type == "VIDEO" val isPortrait: Boolean get() = width > 0 && height > 0 && height > width } class ImmichException(message: String, val code: Int = 0) : IOException(message) /** Minimal Immich REST client (tested against Immich 3.x, compatible with 1.1xx+). */ class ImmichApi(baseUrl: String, private val apiKey: String) { val baseUrl: String = normalizeUrl(baseUrl) val client: OkHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .addInterceptor { chain -> chain.proceed( chain.request().newBuilder() .header("x-api-key", apiKey) .header("Accept", "application/json, image/*, video/*, */*") .build() ) } .build() fun imageUrl(id: String, size: String): String = "$baseUrl/api/assets/$id/thumbnail?size=$size" fun videoUrl(id: String): String = "$baseUrl/api/assets/$id/video/playback" suspend fun me(): String = withContext(Dispatchers.IO) { val json = JSONObject(get("$baseUrl/api/users/me")) json.optString("name").ifBlank { json.optString("email") } } suspend fun albums(): List = withContext(Dispatchers.IO) { val arr = JSONArray(get("$baseUrl/api/albums")) (0 until arr.length()).map { i -> val a = arr.getJSONObject(i) Album(a.getString("id"), a.optString("albumName", "?"), a.optInt("assetCount", 0)) }.sortedBy { it.name.lowercase() } } /** * Random assets. [type] is "IMAGE", "VIDEO" or null for both. * Archived / locked assets are excluded by requesting the timeline visibility. */ suspend fun random( size: Int, albumIds: Collection, type: String?, favoritesOnly: Boolean, ): List = withContext(Dispatchers.IO) { val body = JSONObject().apply { put("size", size) put("withExif", true) put("visibility", "timeline") if (type != null) put("type", type) if (favoritesOnly) put("isFavorite", true) if (albumIds.isNotEmpty()) put("albumIds", JSONArray(albumIds.toList())) } val arr = JSONArray(post("$baseUrl/api/search/random", body)) (0 until arr.length()).mapNotNull { i -> parseAsset(arr.getJSONObject(i)) } } private fun parseAsset(o: JSONObject): Asset? { val id = o.optString("id").ifBlank { return null } val exif = o.optJSONObject("exifInfo") var w = o.optInt("width", 0) var h = o.optInt("height", 0) if (w <= 0 || h <= 0) { // Older servers: derive from EXIF and swap when the orientation tag rotates the image. w = exif?.optInt("exifImageWidth", 0) ?: 0 h = exif?.optInt("exifImageHeight", 0) ?: 0 val orientation = exif?.optString("orientation", "")?.toIntOrNull() ?: 1 if (orientation in 5..8) { val t = w; w = h; h = t } } return Asset( id = id, type = o.optString("type", "IMAGE"), width = w, height = h, fileName = o.optString("originalFileName", ""), takenAt = o.str("localDateTime") ?: exif?.str("dateTimeOriginal"), city = exif?.str("city"), country = exif?.str("country"), durationMs = parseDuration(o.opt("duration")), ) } /** Like optString but treats JSON null and blank as absent instead of returning "null". */ private fun JSONObject.str(key: String): String? = if (isNull(key)) null else optString(key).ifBlank { null } private fun get(url: String): String = execute(Request.Builder().url(url).get().build()) private fun post(url: String, body: JSONObject): String = execute( Request.Builder().url(url).post(body.toString().toRequestBody(JSON)).build() ) private fun execute(request: Request): String { client.newCall(request).execute().use { resp -> val text = resp.body?.string() ?: "" if (!resp.isSuccessful) { val msg = runCatching { JSONObject(text).optString("message") }.getOrNull() throw ImmichException( if (!msg.isNullOrBlank()) "HTTP ${resp.code}: $msg" else "HTTP ${resp.code}", resp.code ) } return text } } companion object { private val JSON = "application/json; charset=utf-8".toMediaType() fun normalizeUrl(raw: String): String { var u = raw.trim().trimEnd('/') if (u.isBlank()) return "" if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u" if (u.endsWith("/api")) u = u.removeSuffix("/api") return u } /** Immich 3.x returns milliseconds; older versions return "H:MM:SS.mmm". */ fun parseDuration(v: Any?): Long = when (v) { null -> 0L is Number -> v.toLong() is String -> { val parts = v.split(":") if (parts.size == 3) { val h = parts[0].toLongOrNull() ?: 0 val m = parts[1].toLongOrNull() ?: 0 val s = parts[2].toDoubleOrNull() ?: 0.0 ((h * 3600 + m * 60) * 1000 + (s * 1000).toLong()) } else v.toLongOrNull() ?: 0L } else -> 0L } /** Logs in with email/password and creates a dedicated API key for this device. Returns the key secret. */ suspend fun createApiKeyWithPassword(baseUrl: String, email: String, password: String, deviceName: String): String = withContext(Dispatchers.IO) { val base = normalizeUrl(baseUrl) val plain = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).build() val loginBody = JSONObject().put("email", email.trim()).put("password", password).toString() val token = plain.newCall( Request.Builder().url("$base/api/auth/login").post(loginBody.toRequestBody(JSON)).build() ).execute().use { resp -> val text = resp.body?.string() ?: "" if (!resp.isSuccessful) { val msg = runCatching { JSONObject(text).optString("message") }.getOrNull() throw ImmichException(if (!msg.isNullOrBlank()) msg else "HTTP ${resp.code}", resp.code) } JSONObject(text).getString("accessToken") } val keyBody = JSONObject() .put("name", deviceName) .put("permissions", JSONArray(listOf("asset.read", "asset.view", "album.read", "user.read"))) .toString() plain.newCall( Request.Builder().url("$base/api/api-keys") .header("Authorization", "Bearer $token") .post(keyBody.toRequestBody(JSON)).build() ).execute().use { resp -> val text = resp.body?.string() ?: "" if (!resp.isSuccessful) { val msg = runCatching { JSONObject(text).optString("message") }.getOrNull() throw ImmichException(if (!msg.isNullOrBlank()) msg else "HTTP ${resp.code}", resp.code) } JSONObject(text).getString("secret") } } } }