e4015e8a90
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
200 lines
8.2 KiB
Kotlin
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")
|
|
}
|
|
}
|
|
}
|
|
}
|