Files
immich-tv-screensaver/app/src/main/kotlin/cloud/alexandrevazquez/immichtv/data/ImmichApi.kt
T
claude-code e4015e8a90 Treat JSON null city/country/date as absent
The overlay showed 'null, null' for photos without location because
JSONObject.optString returns the string "null" for JSON null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BvT3RjNpBmMUsyPqHq7fm
2026-09-11 21:38:15 +00:00

200 lines
8.2 KiB
Kotlin

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<Album> = 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<String>,
type: String?,
favoritesOnly: Boolean,
): List<Asset> = 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")
}
}
}
}