mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-07-08 18:25:09 +00:00
Add linter
This commit is contained in:
@@ -1,272 +0,0 @@
|
||||
package dev.jellylink.jellyfin
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
|
||||
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
|
||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
|
||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioReference
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
||||
import java.io.DataInput
|
||||
import java.io.DataOutput
|
||||
import java.io.IOException
|
||||
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
|
||||
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
|
||||
|
||||
@Service
|
||||
class JellyfinAudioSourceManager(
|
||||
private val config: JellyfinConfig,
|
||||
private val metadataStore: JellyfinMetadataStore
|
||||
) : AudioSourceManager {
|
||||
|
||||
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`
|
||||
|
||||
override fun getSourceName(): String = "jellyfin"
|
||||
|
||||
override fun loadItem(manager: AudioPlayerManager, reference: AudioReference): AudioItem? {
|
||||
val identifier = reference.identifier ?: return null
|
||||
val prefix = "jfsearch:"
|
||||
if (!identifier.startsWith(prefix, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
log.info("Jellyfin source handling identifier: {}", identifier)
|
||||
|
||||
if (!ensureAuthenticated()) {
|
||||
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val query = identifier.substring(prefix.length).trim()
|
||||
if (query.isEmpty()) return null
|
||||
|
||||
val item = searchFirstAudioItem(query)
|
||||
if (item == null) {
|
||||
log.warn("No Jellyfin results found for query: {}", query)
|
||||
return null
|
||||
}
|
||||
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
|
||||
|
||||
val playbackUrl = buildPlaybackUrl(item.id)
|
||||
log.info("Jellyfin playback URL: {}", playbackUrl)
|
||||
|
||||
metadataStore.put(playbackUrl, item)
|
||||
|
||||
val trackInfo = AudioTrackInfo(
|
||||
item.title ?: "Unknown",
|
||||
item.artist ?: "Unknown",
|
||||
item.lengthMs ?: Long.MAX_VALUE,
|
||||
item.id,
|
||||
false,
|
||||
playbackUrl,
|
||||
item.artworkUrl,
|
||||
null
|
||||
)
|
||||
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 && !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"
|
||||
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 200..299) {
|
||||
log.error("Jellyfin auth failed with status {}: {}", response.statusCode(), response.body().take(500))
|
||||
return false
|
||||
}
|
||||
|
||||
log.info("Successfully authenticated with Jellyfin")
|
||||
val responseJson = json.parseToJsonElement(response.body()).jsonObject
|
||||
val token = responseJson["AccessToken"]?.jsonPrimitive?.contentOrNull
|
||||
val uid = responseJson["User"]?.jsonObject?.get("Id")?.jsonPrimitive?.contentOrNull
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun escape(value: String): String {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
}
|
||||
|
||||
private 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 request = HttpRequest.newBuilder()
|
||||
.uri(java.net.URI.create(url))
|
||||
.header("X-Emby-Token", accessToken ?: return null)
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
log.debug("Jellyfin search response: {}", body.take(2000))
|
||||
|
||||
val responseJson = json.parseToJsonElement(body).jsonObject
|
||||
val items = responseJson["Items"]?.jsonArray
|
||||
if (items.isNullOrEmpty()) return null
|
||||
|
||||
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 runTimeTicks = item["RunTimeTicks"]?.jsonPrimitive?.longOrNull
|
||||
val lengthMs = runTimeTicks?.let { it / 10_000 }
|
||||
|
||||
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"
|
||||
} else {
|
||||
"$baseUrl/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
|
||||
)
|
||||
}
|
||||
|
||||
private 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" -> 320000
|
||||
"MEDIUM" -> 192000
|
||||
"LOW" -> 128000
|
||||
else -> {
|
||||
val custom = config.audioQuality.trim().toIntOrNull()
|
||||
if (custom != null) custom * 1000 else 320000
|
||||
}
|
||||
}
|
||||
val codec = config.audioCodec.trim().ifEmpty { "mp3" }
|
||||
|
||||
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
|
||||
}
|
||||
|
||||
override fun isTrackEncodable(track: AudioTrack): Boolean = true
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun encodeTrack(track: AudioTrack, output: DataOutput) {
|
||||
// No additional data to encode beyond AudioTrackInfo.
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun decodeTrack(trackInfo: AudioTrackInfo, input: DataInput): AudioTrack {
|
||||
return JellyfinAudioTrack(trackInfo, this)
|
||||
}
|
||||
|
||||
override fun shutdown() {
|
||||
httpInterfaceManager.close()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package dev.jellylink.jellyfin
|
||||
package dev.jellylink.jellyfin.audio
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||
import dev.arbjerg.lavalink.api.AudioPluginInfoModifier
|
||||
import dev.jellylink.jellyfin.config.JellyfinConfig
|
||||
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.springframework.stereotype.Component
|
||||
@@ -9,12 +11,14 @@ import org.springframework.stereotype.Component
|
||||
@Component
|
||||
class JellyfinAudioPluginInfoModifier(
|
||||
private val metadataStore: JellyfinMetadataStore,
|
||||
private val config: JellyfinConfig
|
||||
private val config: JellyfinConfig,
|
||||
) : AudioPluginInfoModifier {
|
||||
|
||||
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
|
||||
val uri = track.info.uri ?: return null
|
||||
if (!uri.startsWith(config.baseUrl.trimEnd('/'))) return null
|
||||
|
||||
if (!uri.startsWith(config.baseUrl.trimEnd('/'))) {
|
||||
return null
|
||||
}
|
||||
|
||||
val meta = metadataStore.get(uri) ?: return null
|
||||
|
||||
@@ -27,6 +31,10 @@ class JellyfinAudioPluginInfoModifier(
|
||||
meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
return if (map.isEmpty()) null else JsonObject(map)
|
||||
return if (map.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
JsonObject(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package dev.jellylink.jellyfin.audio
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
|
||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
|
||||
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
|
||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
|
||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioReference
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
||||
import dev.jellylink.jellyfin.client.JellyfinApiClient
|
||||
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.DataInput
|
||||
import java.io.DataOutput
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers
|
||||
* against a Jellyfin server.
|
||||
*
|
||||
* This class is intentionally thin — it owns the Lavaplayer contract and delegates
|
||||
* HTTP / parsing work to [JellyfinApiClient] and [dev.jellylink.jellyfin.client.JellyfinResponseParser].
|
||||
*/
|
||||
@Service
|
||||
class JellyfinAudioSourceManager(
|
||||
private val apiClient: JellyfinApiClient,
|
||||
private val metadataStore: JellyfinMetadataStore,
|
||||
) : AudioSourceManager {
|
||||
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
|
||||
|
||||
val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY
|
||||
|
||||
fun getHttpInterface(): HttpInterface = httpInterfaceManager.`interface`
|
||||
|
||||
override fun getSourceName(): String = "jellyfin"
|
||||
|
||||
override fun loadItem(
|
||||
manager: AudioPlayerManager,
|
||||
reference: AudioReference,
|
||||
): AudioItem? {
|
||||
val identifier = reference.identifier ?: return null
|
||||
|
||||
if (!identifier.startsWith(SEARCH_PREFIX, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
|
||||
log.info("Jellyfin source handling identifier: {}", identifier)
|
||||
|
||||
if (!apiClient.ensureAuthenticated()) {
|
||||
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
|
||||
return null
|
||||
}
|
||||
|
||||
val query = identifier.substring(SEARCH_PREFIX.length).trim()
|
||||
|
||||
if (query.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val item = apiClient.searchFirstAudioItem(query)
|
||||
|
||||
if (item == null) {
|
||||
log.warn("No Jellyfin results found for query: {}", query)
|
||||
return null
|
||||
}
|
||||
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
|
||||
|
||||
val playbackUrl = apiClient.buildPlaybackUrl(item.id)
|
||||
log.info("Jellyfin playback URL: {}", playbackUrl)
|
||||
|
||||
metadataStore.put(playbackUrl, item)
|
||||
|
||||
val trackInfo =
|
||||
AudioTrackInfo(
|
||||
item.title ?: "Unknown",
|
||||
item.artist ?: "Unknown",
|
||||
item.lengthMs ?: Long.MAX_VALUE,
|
||||
item.id,
|
||||
false,
|
||||
playbackUrl,
|
||||
item.artworkUrl,
|
||||
null,
|
||||
)
|
||||
|
||||
return JellyfinAudioTrack(trackInfo, this)
|
||||
}
|
||||
|
||||
override fun isTrackEncodable(track: AudioTrack): Boolean = true
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun encodeTrack(
|
||||
track: AudioTrack,
|
||||
output: DataOutput,
|
||||
) {
|
||||
// No additional data to encode beyond AudioTrackInfo.
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun decodeTrack(
|
||||
trackInfo: AudioTrackInfo,
|
||||
input: DataInput,
|
||||
): AudioTrack = JellyfinAudioTrack(trackInfo, this)
|
||||
|
||||
override fun shutdown() {
|
||||
httpInterfaceManager.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinAudioSourceManager::class.java)
|
||||
private const val SEARCH_PREFIX = "jfsearch:"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.jellylink.jellyfin
|
||||
package dev.jellylink.jellyfin.audio
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerDetection
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerHints
|
||||
@@ -11,37 +11,34 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
||||
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor
|
||||
import java.net.URI
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.URI
|
||||
|
||||
class JellyfinAudioTrack(
|
||||
trackInfo: AudioTrackInfo,
|
||||
private val sourceManager: JellyfinAudioSourceManager
|
||||
private val sourceManager: JellyfinAudioSourceManager,
|
||||
) : DelegatedAudioTrack(trackInfo) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinAudioTrack::class.java)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun process(executor: LocalAudioTrackExecutor) {
|
||||
log.info("Processing Jellyfin track: {} ({})", trackInfo.title, trackInfo.uri)
|
||||
|
||||
sourceManager.getHttpInterface().use { httpInterface ->
|
||||
PersistentHttpStream(httpInterface, URI(trackInfo.uri), trackInfo.length).use { stream ->
|
||||
val result = MediaContainerDetection(
|
||||
sourceManager.containerRegistry,
|
||||
AudioReference(trackInfo.uri, trackInfo.title),
|
||||
stream,
|
||||
MediaContainerHints.from(null, null)
|
||||
).detectContainer()
|
||||
val result =
|
||||
MediaContainerDetection(
|
||||
sourceManager.containerRegistry,
|
||||
AudioReference(trackInfo.uri, trackInfo.title),
|
||||
stream,
|
||||
MediaContainerHints.from(null, null),
|
||||
).detectContainer()
|
||||
|
||||
if (result == null || !result.isContainerDetected) {
|
||||
log.error("Could not detect audio container for Jellyfin track: {}", trackInfo.title)
|
||||
|
||||
throw FriendlyException(
|
||||
"Could not detect audio format from Jellyfin stream",
|
||||
FriendlyException.Severity.COMMON,
|
||||
null
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,9 +50,9 @@ class JellyfinAudioTrack(
|
||||
result.containerDescriptor.probe.createTrack(
|
||||
result.containerDescriptor.parameters,
|
||||
trackInfo,
|
||||
stream
|
||||
stream,
|
||||
) as InternalAudioTrack,
|
||||
executor
|
||||
executor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,4 +61,8 @@ class JellyfinAudioTrack(
|
||||
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
|
||||
|
||||
override fun getSourceManager(): AudioSourceManager = sourceManager
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinAudioTrack::class.java)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.jellylink.jellyfin
|
||||
package dev.jellylink.jellyfin.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.stereotype.Component
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.jellylink.jellyfin.model
|
||||
|
||||
data class JellyfinMetadata(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val artist: String?,
|
||||
val album: String?,
|
||||
val lengthMs: Long?,
|
||||
val artworkUrl: String?,
|
||||
)
|
||||
@@ -1,22 +1,16 @@
|
||||
package dev.jellylink.jellyfin
|
||||
package dev.jellylink.jellyfin.model
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
data class JellyfinMetadata(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val artist: String?,
|
||||
val album: String?,
|
||||
val lengthMs: Long?,
|
||||
val artworkUrl: String?
|
||||
)
|
||||
|
||||
@Component
|
||||
class JellyfinMetadataStore {
|
||||
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
|
||||
|
||||
fun put(url: String, metadata: JellyfinMetadata) {
|
||||
fun put(
|
||||
url: String,
|
||||
metadata: JellyfinMetadata,
|
||||
) {
|
||||
data[url] = metadata
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user