From 7c6e96b1a2c9f6e397c895564f76357552b0d9ad Mon Sep 17 00:00:00 2001 From: Myx Date: Sun, 15 Feb 2026 18:28:03 +0100 Subject: [PATCH] Refactor and remove manual parsing --- README.md | 1 + build.gradle.kts | 28 +-- gradle/libs.versions.toml | 6 + .../jellyfin/JellyfinAudioSourceManager.kt | 186 +++++++----------- .../dev/jellylink/jellyfin/JellyfinConfig.kt | 8 + .../lavalink-plugins/jellylink.properties | 3 - 6 files changed, 93 insertions(+), 139 deletions(-) create mode 100644 gradle/libs.versions.toml delete mode 100644 src/main/resources/lavalink-plugins/jellylink.properties diff --git a/README.md b/README.md index d411159..053fa2a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ plugins: searchLimit: 5 # max results to return (default: 5) audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom kbps audioCodec: "mp3" # only used when audioQuality is not ORIGINAL + tokenRefreshMinutes: 30 # re-authenticate every N minutes (0 = only on 401) ``` #### Audio Quality Options diff --git a/build.gradle.kts b/build.gradle.kts index 6178d25..7de4f98 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,35 +1,21 @@ plugins { kotlin("jvm") version "1.8.22" - `java-library` + alias(libs.plugins.lavalink) } group = "dev.jellylink" version = "0.1.0" -repositories { - // Lavalink / Lavaplayer artifacts - maven("https://maven.lavalink.dev/releases") - - mavenCentral() -} - -dependencies { - // Lavalink plugin API (adjust version to match your Lavalink server) - compileOnly("dev.arbjerg.lavalink:plugin-api:4.0.8") - - // Lavaplayer (provided by Lavalink at runtime; keep as compileOnly) - compileOnly("dev.arbjerg:lavaplayer:2.2.2") - - // Spring annotations (provided by Lavalink, but needed for compilation) - compileOnly("org.springframework.boot:spring-boot-starter-web:3.1.0") - - // JSON types used by the plugin API - compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") +lavalinkPlugin { + name = "jellylink-jellyfin" + path = "dev.jellylink" + apiVersion = libs.versions.lavalink.api + serverVersion = libs.versions.lavalink.server } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion = JavaLanguageVersion.of(17) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..c1f94cf --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,6 @@ +[versions] +lavalink-api = "4.0.8" +lavalink-server = "4.0.8" + +[plugins] +lavalink = { id = "dev.arbjerg.lavalink.gradle-plugin", version = "1.0.15" } diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt index ed47452..e38f5b1 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt @@ -17,7 +17,14 @@ 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 +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 import org.springframework.stereotype.Service @@ -29,12 +36,15 @@ class JellyfinAudioSourceManager( private val log = LoggerFactory.getLogger(JellyfinAudioSourceManager::class.java) private val httpClient: HttpClient = HttpClient.newHttpClient() + private val json = Json { ignoreUnknownKeys = true } val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager() @Volatile private var accessToken: String? = null @Volatile private var userId: String? = null + @Volatile + private var tokenObtainedAt: Instant? = null fun getHttpInterface(): HttpInterface = httpInterfaceManager.`interface` @@ -81,8 +91,30 @@ class JellyfinAudioSourceManager( return JellyfinAudioTrack(trackInfo, this) } + /** + * Invalidate the current token so the next call to [ensureAuthenticated] will re-authenticate. + */ + private 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 // automatic refresh disabled + val obtainedAt = tokenObtainedAt ?: return true + return Instant.now().isAfter(obtainedAt.plusSeconds(refreshMinutes * 60L)) + } + private fun ensureAuthenticated(): Boolean { - if (accessToken != null && userId != null) return true + if (accessToken != null && userId != null && !isTokenExpired()) return true + // Token missing or expired — (re-)authenticate + 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 val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName" @@ -104,28 +136,18 @@ class JellyfinAudioSourceManager( } log.info("Successfully authenticated with Jellyfin") - val bodyText = response.body() - val tokenKey = "\"AccessToken\":\"" - val tokenIndex = bodyText.indexOf(tokenKey) - if (tokenIndex == -1) return false - val tokenStart = tokenIndex + tokenKey.length - val tokenEnd = bodyText.indexOf('"', tokenStart) - if (tokenEnd <= tokenStart) return false - val token = bodyText.substring(tokenStart, tokenEnd) + val responseJson = json.parseToJsonElement(response.body()).jsonObject + val token = responseJson["AccessToken"]?.jsonPrimitive?.contentOrNull + val uid = responseJson["User"]?.jsonObject?.get("Id")?.jsonPrimitive?.contentOrNull - val userKey = "\"User\":{" - val userIndex = bodyText.indexOf(userKey) - if (userIndex == -1) return false - val idKey = "\"Id\":\"" - val idIndex = bodyText.indexOf(idKey, userIndex) - if (idIndex == -1) return false - val idStart = idIndex + idKey.length - val idEnd = bodyText.indexOf('"', idStart) - if (idEnd <= idStart) return false - val uid = bodyText.substring(idStart, idEnd) + if (token == null || uid == null) { + log.error("Jellyfin auth response missing AccessToken or User.Id") + return false + } accessToken = token userId = uid + tokenObtainedAt = Instant.now() return true } @@ -150,7 +172,24 @@ class JellyfinAudioSourceManager( .GET() .build() - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // If 401, the token may have been revoked server-side — re-authenticate and retry once + if (response.statusCode() == 401) { + log.warn("Jellyfin search returned 401 — token may have been revoked, re-authenticating") + invalidateToken() + if (!ensureAuthenticated()) { + log.error("Jellyfin re-authentication failed after 401") + return null + } + val retryRequest = HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .header("X-Emby-Token", accessToken ?: return null) + .GET() + .build() + response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString()) + } + if (response.statusCode() !in 200..299) { log.error("Jellyfin search failed with status {}: {}", response.statusCode(), response.body().take(500)) return null @@ -159,26 +198,21 @@ class JellyfinAudioSourceManager( val body = response.body() log.debug("Jellyfin search response: {}", body.take(2000)) - // Find the first item in the Items array - val itemsIdx = body.indexOf("\"Items\":[") - if (itemsIdx == -1) return null - val firstItemStart = body.indexOf("{", itemsIdx + 9) - if (firstItemStart == -1) return null + val responseJson = json.parseToJsonElement(body).jsonObject + val items = responseJson["Items"]?.jsonArray + if (items.isNullOrEmpty()) return null - // Take a generous chunk for the first item - val itemChunk = body.substring(firstItemStart, minOf(body.length, firstItemStart + 5000)) + val item = items[0].jsonObject + 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 id = extractJsonString(itemChunk, "Id") ?: return null - val title = extractJsonString(itemChunk, "Name") - val artist = extractJsonString(itemChunk, "AlbumArtist") - ?: extractFirstArrayElement(itemChunk, "Artists") - val album = extractJsonString(itemChunk, "Album") + val runTimeTicks = item["RunTimeTicks"]?.jsonPrimitive?.longOrNull + val lengthMs = runTimeTicks?.let { it / 10_000 } - val runtimeTicks = extractJsonLong(itemChunk, "RunTimeTicks") - val lengthMs = runtimeTicks?.let { it / 10_000 } - - val imageTag = extractJsonString(itemChunk, "Primary") - // Always provide an artwork URL — Jellyfin will serve the image even without the tag param + val imageTag = item["ImageTags"]?.jsonObject?.get("Primary")?.jsonPrimitive?.contentOrNull val baseUrl = config.baseUrl.trimEnd('/') val artUrl = if (imageTag != null) { "$baseUrl/Items/$id/Images/Primary?tag=$imageTag" @@ -197,84 +231,6 @@ class JellyfinAudioSourceManager( ) } - private fun extractJsonString(json: String, key: String): String? { - val pattern = "\"$key\":\"" - val idx = json.indexOf(pattern) - if (idx == -1) return null - val start = idx + pattern.length - val end = findUnescapedQuote(json, start) - return if (end > start) unescapeJson(json.substring(start, end)) else null - } - - private fun extractFirstArrayElement(json: String, key: String): String? { - val pattern = "\"$key\":[\"" - val idx = json.indexOf(pattern) - if (idx == -1) return null - val start = idx + pattern.length - val end = findUnescapedQuote(json, start) - return if (end > start) unescapeJson(json.substring(start, end)) else null - } - - /** Find the next unescaped double-quote starting from [from]. */ - private fun findUnescapedQuote(json: String, from: Int): Int { - var i = from - while (i < json.length) { - when (json[i]) { - '\\' -> i += 2 // skip escaped character - '"' -> return i - else -> i++ - } - } - return -1 - } - - /** Decode JSON string escape sequences: \\uXXXX, \\n, \\t, \\\\, \\", etc. */ - private fun unescapeJson(s: String): String { - if (!s.contains('\\')) return s - val sb = StringBuilder(s.length) - var i = 0 - while (i < s.length) { - if (s[i] == '\\' && i + 1 < s.length) { - when (s[i + 1]) { - 'u' -> { - if (i + 5 < s.length) { - val hex = s.substring(i + 2, i + 6) - val cp = hex.toIntOrNull(16) - if (cp != null) { - sb.append(cp.toChar()) - i += 6 - continue - } - } - sb.append(s[i]) - i++ - } - 'n' -> { sb.append('\n'); i += 2 } - 't' -> { sb.append('\t'); i += 2 } - 'r' -> { sb.append('\r'); i += 2 } - '\\' -> { sb.append('\\'); i += 2 } - '"' -> { sb.append('"'); i += 2 } - '/' -> { sb.append('/'); i += 2 } - else -> { sb.append(s[i]); i++ } - } - } else { - sb.append(s[i]) - i++ - } - } - return sb.toString() - } - - private fun extractJsonLong(json: String, key: String): Long? { - val pattern = "\"$key\":" - val idx = json.indexOf(pattern) - if (idx == -1) return null - val start = idx + pattern.length - var end = start - while (end < json.length && json[end].isDigit()) end++ - return if (end > start) json.substring(start, end).toLongOrNull() else null - } - private fun buildPlaybackUrl(itemId: String): String { val base = config.baseUrl.trimEnd('/') val token = accessToken ?: "" diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt index 13cbcd6..d6ec9dd 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt @@ -27,4 +27,12 @@ class JellyfinConfig { * Default: "mp3" */ var audioCodec: String = "mp3" + + /** + * How often (in minutes) the Jellyfin access token should be refreshed. + * The plugin will re-authenticate automatically before the token expires. + * Set to 0 to disable automatic refresh (token is obtained once and reused until a 401 occurs). + * Default: 30 + */ + var tokenRefreshMinutes: Int = 30 } diff --git a/src/main/resources/lavalink-plugins/jellylink.properties b/src/main/resources/lavalink-plugins/jellylink.properties deleted file mode 100644 index a1a8dea..0000000 --- a/src/main/resources/lavalink-plugins/jellylink.properties +++ /dev/null @@ -1,3 +0,0 @@ -name=jellylink-jellyfin -path=dev.jellylink -version=0.1.0