mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-07-07 17:55:08 +00:00
Refactor and remove manual parsing
This commit is contained in:
@@ -77,6 +77,7 @@ plugins:
|
|||||||
searchLimit: 5 # max results to return (default: 5)
|
searchLimit: 5 # max results to return (default: 5)
|
||||||
audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom kbps
|
audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom kbps
|
||||||
audioCodec: "mp3" # only used when audioQuality is not ORIGINAL
|
audioCodec: "mp3" # only used when audioQuality is not ORIGINAL
|
||||||
|
tokenRefreshMinutes: 30 # re-authenticate every N minutes (0 = only on 401)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Audio Quality Options
|
#### Audio Quality Options
|
||||||
|
|||||||
@@ -1,35 +1,21 @@
|
|||||||
plugins {
|
plugins {
|
||||||
kotlin("jvm") version "1.8.22"
|
kotlin("jvm") version "1.8.22"
|
||||||
`java-library`
|
alias(libs.plugins.lavalink)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "dev.jellylink"
|
group = "dev.jellylink"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
repositories {
|
lavalinkPlugin {
|
||||||
// Lavalink / Lavaplayer artifacts
|
name = "jellylink-jellyfin"
|
||||||
maven("https://maven.lavalink.dev/releases")
|
path = "dev.jellylink"
|
||||||
|
apiVersion = libs.versions.lavalink.api
|
||||||
mavenCentral()
|
serverVersion = libs.versions.lavalink.server
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion.set(JavaLanguageVersion.of(17))
|
languageVersion = JavaLanguageVersion.of(17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
gradle/libs.versions.toml
Normal file
6
gradle/libs.versions.toml
Normal file
@@ -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" }
|
||||||
@@ -17,7 +17,14 @@ import java.net.http.HttpClient
|
|||||||
import java.net.http.HttpRequest
|
import java.net.http.HttpRequest
|
||||||
import java.net.http.HttpResponse
|
import java.net.http.HttpResponse
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.time.Instant
|
||||||
import java.util.UUID
|
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.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
@@ -29,12 +36,15 @@ class JellyfinAudioSourceManager(
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(JellyfinAudioSourceManager::class.java)
|
private val log = LoggerFactory.getLogger(JellyfinAudioSourceManager::class.java)
|
||||||
private val httpClient: HttpClient = HttpClient.newHttpClient()
|
private val httpClient: HttpClient = HttpClient.newHttpClient()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY
|
val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY
|
||||||
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
|
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
|
||||||
@Volatile
|
@Volatile
|
||||||
private var accessToken: String? = null
|
private var accessToken: String? = null
|
||||||
@Volatile
|
@Volatile
|
||||||
private var userId: String? = null
|
private var userId: String? = null
|
||||||
|
@Volatile
|
||||||
|
private var tokenObtainedAt: Instant? = null
|
||||||
|
|
||||||
fun getHttpInterface(): HttpInterface = httpInterfaceManager.`interface`
|
fun getHttpInterface(): HttpInterface = httpInterfaceManager.`interface`
|
||||||
|
|
||||||
@@ -81,8 +91,30 @@ class JellyfinAudioSourceManager(
|
|||||||
return JellyfinAudioTrack(trackInfo, this)
|
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 {
|
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
|
if (config.baseUrl.isBlank() || config.username.isBlank() || config.password.isBlank()) return false
|
||||||
|
|
||||||
val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName"
|
val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName"
|
||||||
@@ -104,28 +136,18 @@ class JellyfinAudioSourceManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info("Successfully authenticated with Jellyfin")
|
log.info("Successfully authenticated with Jellyfin")
|
||||||
val bodyText = response.body()
|
val responseJson = json.parseToJsonElement(response.body()).jsonObject
|
||||||
val tokenKey = "\"AccessToken\":\""
|
val token = responseJson["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||||
val tokenIndex = bodyText.indexOf(tokenKey)
|
val uid = responseJson["User"]?.jsonObject?.get("Id")?.jsonPrimitive?.contentOrNull
|
||||||
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 userKey = "\"User\":{"
|
if (token == null || uid == null) {
|
||||||
val userIndex = bodyText.indexOf(userKey)
|
log.error("Jellyfin auth response missing AccessToken or User.Id")
|
||||||
if (userIndex == -1) return false
|
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)
|
|
||||||
|
|
||||||
accessToken = token
|
accessToken = token
|
||||||
userId = uid
|
userId = uid
|
||||||
|
tokenObtainedAt = Instant.now()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +172,24 @@ class JellyfinAudioSourceManager(
|
|||||||
.GET()
|
.GET()
|
||||||
.build()
|
.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) {
|
if (response.statusCode() !in 200..299) {
|
||||||
log.error("Jellyfin search failed with status {}: {}", response.statusCode(), response.body().take(500))
|
log.error("Jellyfin search failed with status {}: {}", response.statusCode(), response.body().take(500))
|
||||||
return null
|
return null
|
||||||
@@ -159,26 +198,21 @@ class JellyfinAudioSourceManager(
|
|||||||
val body = response.body()
|
val body = response.body()
|
||||||
log.debug("Jellyfin search response: {}", body.take(2000))
|
log.debug("Jellyfin search response: {}", body.take(2000))
|
||||||
|
|
||||||
// Find the first item in the Items array
|
val responseJson = json.parseToJsonElement(body).jsonObject
|
||||||
val itemsIdx = body.indexOf("\"Items\":[")
|
val items = responseJson["Items"]?.jsonArray
|
||||||
if (itemsIdx == -1) return null
|
if (items.isNullOrEmpty()) return null
|
||||||
val firstItemStart = body.indexOf("{", itemsIdx + 9)
|
|
||||||
if (firstItemStart == -1) return null
|
|
||||||
|
|
||||||
// Take a generous chunk for the first item
|
val item = items[0].jsonObject
|
||||||
val itemChunk = body.substring(firstItemStart, minOf(body.length, firstItemStart + 5000))
|
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 runTimeTicks = item["RunTimeTicks"]?.jsonPrimitive?.longOrNull
|
||||||
val title = extractJsonString(itemChunk, "Name")
|
val lengthMs = runTimeTicks?.let { it / 10_000 }
|
||||||
val artist = extractJsonString(itemChunk, "AlbumArtist")
|
|
||||||
?: extractFirstArrayElement(itemChunk, "Artists")
|
|
||||||
val album = extractJsonString(itemChunk, "Album")
|
|
||||||
|
|
||||||
val runtimeTicks = extractJsonLong(itemChunk, "RunTimeTicks")
|
val imageTag = item["ImageTags"]?.jsonObject?.get("Primary")?.jsonPrimitive?.contentOrNull
|
||||||
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 baseUrl = config.baseUrl.trimEnd('/')
|
val baseUrl = config.baseUrl.trimEnd('/')
|
||||||
val artUrl = if (imageTag != null) {
|
val artUrl = if (imageTag != null) {
|
||||||
"$baseUrl/Items/$id/Images/Primary?tag=$imageTag"
|
"$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 {
|
private fun buildPlaybackUrl(itemId: String): String {
|
||||||
val base = config.baseUrl.trimEnd('/')
|
val base = config.baseUrl.trimEnd('/')
|
||||||
val token = accessToken ?: ""
|
val token = accessToken ?: ""
|
||||||
|
|||||||
@@ -27,4 +27,12 @@ class JellyfinConfig {
|
|||||||
* Default: "mp3"
|
* Default: "mp3"
|
||||||
*/
|
*/
|
||||||
var audioCodec: String = "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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
name=jellylink-jellyfin
|
|
||||||
path=dev.jellylink
|
|
||||||
version=0.1.0
|
|
||||||
Reference in New Issue
Block a user