mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-07-09 02:35:08 +00:00
Add linter
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package dev.jellylink.jellyfin.client
|
||||
|
||||
import dev.jellylink.jellyfin.config.JellyfinConfig
|
||||
import dev.jellylink.jellyfin.model.JellyfinMetadata
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Handles all HTTP communication with the Jellyfin server.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Authentication (login, token refresh, invalidation)
|
||||
* - Sending search requests (with automatic 401 retry)
|
||||
* - Building audio playback URLs
|
||||
*/
|
||||
@Component
|
||||
class JellyfinApiClient(
|
||||
private val config: JellyfinConfig,
|
||||
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(),
|
||||
) {
|
||||
@Volatile
|
||||
var accessToken: String? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var userId: String? = null
|
||||
|
||||
@Volatile
|
||||
private var tokenObtainedAt: Instant? = null
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Authentication
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ensure a valid access token is available, authenticating if necessary.
|
||||
*
|
||||
* @return `true` when a valid token is ready for use
|
||||
*/
|
||||
fun ensureAuthenticated(): Boolean {
|
||||
if (accessToken != null && userId != null && !isTokenExpired()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (accessToken != null && isTokenExpired()) {
|
||||
log.info("Jellyfin access token expired after {} minutes, re-authenticating", config.tokenRefreshMinutes)
|
||||
invalidateToken()
|
||||
}
|
||||
|
||||
if (config.baseUrl.isBlank() || config.username.isBlank() || config.password.isBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return authenticate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the current token so the next call will re-authenticate.
|
||||
*/
|
||||
fun invalidateToken() {
|
||||
log.info("Invalidating Jellyfin access token")
|
||||
accessToken = null
|
||||
userId = null
|
||||
tokenObtainedAt = null
|
||||
}
|
||||
|
||||
private fun isTokenExpired(): Boolean {
|
||||
val refreshMinutes = config.tokenRefreshMinutes
|
||||
|
||||
if (refreshMinutes <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val obtainedAt = tokenObtainedAt ?: return true
|
||||
|
||||
return Instant.now().isAfter(obtainedAt.plusSeconds(refreshMinutes * SECONDS_PER_MINUTE))
|
||||
}
|
||||
|
||||
private fun authenticate(): Boolean {
|
||||
val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName"
|
||||
val body = """{"Username":"${escape(config.username)}","Pw":"${escape(config.password)}"}"""
|
||||
val authHeader = "MediaBrowser Client=\"Jellylink\", Device=\"Lavalink\", DeviceId=\"${UUID.randomUUID()}\", Version=\"0.1.0\""
|
||||
|
||||
val request = HttpRequest
|
||||
.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", authHeader)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build()
|
||||
|
||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
|
||||
if (response.statusCode() !in HTTP_OK_RANGE) {
|
||||
log.error("Jellyfin auth failed with status {}: {}", response.statusCode(), response.body().take(ERROR_BODY_PREVIEW_LENGTH))
|
||||
return false
|
||||
}
|
||||
|
||||
log.info("Successfully authenticated with Jellyfin")
|
||||
val result = responseParser.parseAuthResponse(response.body()) ?: return false
|
||||
|
||||
accessToken = result.accessToken
|
||||
userId = result.userId
|
||||
tokenObtainedAt = Instant.now()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Search Jellyfin for the first audio item matching [query].
|
||||
*
|
||||
* Handles 401 retry transparently.
|
||||
*
|
||||
* @return parsed [JellyfinMetadata], or `null` if no result / error
|
||||
*/
|
||||
fun searchFirstAudioItem(query: String): JellyfinMetadata? {
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8)
|
||||
val url = StringBuilder()
|
||||
.append(config.baseUrl.trimEnd('/'))
|
||||
.append("/Items?SearchTerm=")
|
||||
.append(encodedQuery)
|
||||
.append("&IncludeItemTypes=Audio&Recursive=true&Limit=")
|
||||
.append(config.searchLimit)
|
||||
.append("&Fields=Artists,AlbumArtist,MediaSources,ImageTags")
|
||||
.toString()
|
||||
|
||||
val response = executeGetWithRetry(url) ?: return null
|
||||
|
||||
if (response.statusCode() !in HTTP_OK_RANGE) {
|
||||
log.error("Jellyfin search failed with status {}: {}", response.statusCode(), response.body().take(ERROR_BODY_PREVIEW_LENGTH))
|
||||
return null
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
log.debug("Jellyfin search response: {}", body.take(DEBUG_BODY_PREVIEW_LENGTH))
|
||||
|
||||
return responseParser.parseFirstAudioItem(body, config.baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a GET request, retrying once on 401 (server-side token revocation).
|
||||
*/
|
||||
private fun executeGetWithRetry(url: String): HttpResponse<String>? {
|
||||
val request = buildGetRequest(url) ?: return null
|
||||
|
||||
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
|
||||
if (response.statusCode() == HTTP_UNAUTHORIZED) {
|
||||
log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating")
|
||||
invalidateToken()
|
||||
|
||||
if (!ensureAuthenticated()) {
|
||||
log.error("Jellyfin re-authentication failed after 401")
|
||||
return null
|
||||
}
|
||||
|
||||
val retryRequest = buildGetRequest(url) ?: return null
|
||||
response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString())
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun buildGetRequest(url: String): HttpRequest? {
|
||||
val token = accessToken ?: return null
|
||||
|
||||
return HttpRequest
|
||||
.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.header("X-Emby-Token", token)
|
||||
.GET()
|
||||
.build()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Playback URL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a streaming URL for the given Jellyfin item, respecting audio quality settings.
|
||||
*/
|
||||
fun buildPlaybackUrl(itemId: String): String {
|
||||
val base = config.baseUrl.trimEnd('/')
|
||||
val token = accessToken ?: ""
|
||||
val quality = config.audioQuality.trim().uppercase()
|
||||
|
||||
if (quality == "ORIGINAL") {
|
||||
return "$base/Audio/$itemId/stream?static=true&api_key=$token"
|
||||
}
|
||||
|
||||
val bitrate =
|
||||
when (quality) {
|
||||
"HIGH" -> BITRATE_HIGH
|
||||
"MEDIUM" -> BITRATE_MEDIUM
|
||||
"LOW" -> BITRATE_LOW
|
||||
else -> {
|
||||
val custom = config.audioQuality.trim().toIntOrNull()
|
||||
|
||||
if (custom != null) {
|
||||
custom * KBPS_TO_BPS
|
||||
} else {
|
||||
BITRATE_HIGH
|
||||
}
|
||||
}
|
||||
}
|
||||
val codec = config.audioCodec.trim().ifEmpty { "mp3" }
|
||||
|
||||
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun escape(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java)
|
||||
private val httpClient: HttpClient = HttpClient.newHttpClient()
|
||||
|
||||
private val HTTP_OK_RANGE = 200..299
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
private const val ERROR_BODY_PREVIEW_LENGTH = 500
|
||||
private const val DEBUG_BODY_PREVIEW_LENGTH = 2000
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val BITRATE_HIGH = 320_000
|
||||
private const val BITRATE_MEDIUM = 192_000
|
||||
private const val BITRATE_LOW = 128_000
|
||||
private const val KBPS_TO_BPS = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package dev.jellylink.jellyfin.client
|
||||
|
||||
import dev.jellylink.jellyfin.model.JellyfinMetadata
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Stateless parser for Jellyfin API JSON responses.
|
||||
*
|
||||
* Converts raw JSON strings into domain objects used by the plugin.
|
||||
*/
|
||||
class JellyfinResponseParser(
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) {
|
||||
/**
|
||||
* Result of parsing an authentication response.
|
||||
*/
|
||||
data class AuthResult(
|
||||
val accessToken: String,
|
||||
val userId: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extract [AuthResult] from the Jellyfin AuthenticateByName response body.
|
||||
*
|
||||
* @return parsed result, or `null` if the required fields are missing
|
||||
*/
|
||||
fun parseAuthResponse(body: String): AuthResult? {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val token = root["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
val userId =
|
||||
root["User"]
|
||||
?.jsonObject
|
||||
?.get("Id")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
|
||||
if (token == null || userId == null) {
|
||||
log.error("Jellyfin auth response missing AccessToken or User.Id")
|
||||
return null
|
||||
}
|
||||
|
||||
return AuthResult(accessToken = token, userId = userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Items array from a Jellyfin search response and return the first audio item.
|
||||
*
|
||||
* @param body raw JSON response body
|
||||
* @param baseUrl Jellyfin server base URL (used for artwork URL construction)
|
||||
* @return the first [JellyfinMetadata] found, or `null`
|
||||
*/
|
||||
fun parseFirstAudioItem(
|
||||
body: String,
|
||||
baseUrl: String,
|
||||
): JellyfinMetadata? {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val items = root["Items"]?.jsonArray
|
||||
|
||||
if (items.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseAudioItem(items[0].jsonObject, baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single Jellyfin item JSON object into [JellyfinMetadata].
|
||||
*/
|
||||
private fun parseAudioItem(
|
||||
item: kotlinx.serialization.json.JsonObject,
|
||||
baseUrl: String,
|
||||
): JellyfinMetadata? {
|
||||
val id = item["Id"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||
val title = item["Name"]?.jsonPrimitive?.contentOrNull
|
||||
val artist = item["AlbumArtist"]?.jsonPrimitive?.contentOrNull
|
||||
?: item["Artists"]
|
||||
?.jsonArray
|
||||
?.firstOrNull()
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
|
||||
val album = item["Album"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
val runTimeTicks = item["RunTimeTicks"]?.jsonPrimitive?.longOrNull
|
||||
val lengthMs = runTimeTicks?.let { it / TICKS_PER_MILLISECOND }
|
||||
|
||||
val imageTag = item["ImageTags"]
|
||||
?.jsonObject
|
||||
?.get("Primary")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
|
||||
val normalizedBase = baseUrl.trimEnd('/')
|
||||
val artUrl =
|
||||
if (imageTag != null) {
|
||||
"$normalizedBase/Items/$id/Images/Primary?tag=$imageTag"
|
||||
} else {
|
||||
"$normalizedBase/Items/$id/Images/Primary"
|
||||
}
|
||||
|
||||
log.info("Jellyfin artwork URL: {} (tag={})", artUrl, imageTag)
|
||||
|
||||
return JellyfinMetadata(
|
||||
id = id,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
lengthMs = lengthMs,
|
||||
artworkUrl = artUrl,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinResponseParser::class.java)
|
||||
private const val TICKS_PER_MILLISECOND = 10_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user