Merge pull request #3 from Myxelium/minor-refactoring

Minor refactoring and cleaning
This commit is contained in:
2026-02-16 23:28:45 +01:00
committed by GitHub
9 changed files with 196 additions and 206 deletions

View File

@@ -17,3 +17,4 @@ ktlint_standard_no-consecutive-blank-lines = enabled
ktlint_standard_trailing-comma-on-call-site = disabled ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled ktlint_standard_trailing-comma-on-declaration-site = disabled
ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_multiline-expression-wrapping = disabled
ktlint_standard_function-expression-body = disabled

View File

@@ -6,7 +6,7 @@ plugins {
} }
group = "dev.jellylink" group = "dev.jellylink"
version = "0.2.0" version = "0.2.1"
lavalinkPlugin { lavalinkPlugin {
name = "jellylink-jellyfin" name = "jellylink-jellyfin"
@@ -25,9 +25,6 @@ kotlin {
jvmToolchain(17) jvmToolchain(17)
} }
// ---------------------------------------------------------------------------
// Detekt — static analysis
// ---------------------------------------------------------------------------
detekt { detekt {
config.setFrom(files("$rootDir/detekt.yml")) config.setFrom(files("$rootDir/detekt.yml"))
buildUponDefaultConfig = true buildUponDefaultConfig = true
@@ -47,9 +44,6 @@ tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
exclude("**/generated/**") exclude("**/generated/**")
} }
// ---------------------------------------------------------------------------
// ktlint — formatting
// ---------------------------------------------------------------------------
ktlint { ktlint {
version.set("1.5.0") version.set("1.5.0")
android.set(false) android.set(false)
@@ -60,9 +54,6 @@ ktlint {
} }
} }
// ---------------------------------------------------------------------------
// Wire lint checks into the build lifecycle
// ---------------------------------------------------------------------------
tasks.named("check") { tasks.named("check") {
dependsOn("detekt", "ktlintCheck") dependsOn("detekt", "ktlintCheck")
} }

View File

@@ -1,12 +1,3 @@
# =============================================================================
# Detekt configuration — strict backend production standard
# =============================================================================
# Built on top of the default config (buildUponDefaultConfig = true in Gradle).
# Only overrides and activations listed here; everything else uses defaults.
# ---------------------------------------------------------------------------
# Complexity rules
# ---------------------------------------------------------------------------
complexity: complexity:
# Cyclomatic complexity per function — matches ESLint complexity: 20 # Cyclomatic complexity per function — matches ESLint complexity: 20
CyclomaticComplexMethod: CyclomaticComplexMethod:
@@ -47,9 +38,6 @@ complexity:
active: true active: true
threshold: 80 threshold: 80
# ---------------------------------------------------------------------------
# Empty blocks — no empty functions or classes
# ---------------------------------------------------------------------------
empty-blocks: empty-blocks:
EmptyFunctionBlock: EmptyFunctionBlock:
active: true active: true
@@ -71,9 +59,6 @@ empty-blocks:
EmptyWhenBlock: EmptyWhenBlock:
active: true active: true
# ---------------------------------------------------------------------------
# Naming conventions — forbid short / misleading identifiers
# ---------------------------------------------------------------------------
naming: naming:
ForbiddenClassName: ForbiddenClassName:
active: false active: false
@@ -91,10 +76,10 @@ naming:
TopLevelPropertyNaming: TopLevelPropertyNaming:
active: true active: true
# ---------------------------------------------------------------------------
# Style rules — val preference, visibility, imports, member ordering
# ---------------------------------------------------------------------------
style: style:
ExpressionBodySyntax:
active: false
# Allow guard-clause heavy functions (early returns are idiomatic Kotlin) # Allow guard-clause heavy functions (early returns are idiomatic Kotlin)
ReturnCount: ReturnCount:
active: true active: true
@@ -153,8 +138,6 @@ style:
excludePackageStatements: true excludePackageStatements: true
excludeImportStatements: true excludeImportStatements: true
# Prefer expression body for simple single-expression functions
OptionalAbstractKeyword: OptionalAbstractKeyword:
active: true active: true
@@ -168,9 +151,6 @@ style:
singleLine: always singleLine: always
multiLine: always multiLine: always
# ---------------------------------------------------------------------------
# Potential bugs
# ---------------------------------------------------------------------------
potential-bugs: potential-bugs:
EqualsAlwaysReturnsTrueOrFalse: EqualsAlwaysReturnsTrueOrFalse:
active: true active: true
@@ -181,9 +161,6 @@ potential-bugs:
IteratorNotThrowingNoSuchElementException: IteratorNotThrowingNoSuchElementException:
active: true active: true
# ---------------------------------------------------------------------------
# Performance
# ---------------------------------------------------------------------------
performance: performance:
SpreadOperator: SpreadOperator:
active: false active: false
@@ -191,9 +168,6 @@ performance:
ForEachOnRange: ForEachOnRange:
active: true active: true
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
exceptions: exceptions:
TooGenericExceptionCaught: TooGenericExceptionCaught:
active: true active: true
@@ -220,7 +194,3 @@ exceptions:
- "MalformedURLException" - "MalformedURLException"
- "NumberFormatException" - "NumberFormatException"
- "ParseException" - "ParseException"
# ---------------------------------------------------------------------------
# Global exclusions — handled in build.gradle.kts (source.setFrom, exclude)
# ---------------------------------------------------------------------------

View File

@@ -2,33 +2,26 @@ package dev.jellylink.jellyfin.audio
import com.sedmelluq.discord.lavaplayer.track.AudioTrack import com.sedmelluq.discord.lavaplayer.track.AudioTrack
import dev.arbjerg.lavalink.api.AudioPluginInfoModifier 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.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class JellyfinAudioPluginInfoModifier( class JellyfinAudioPluginInfoModifier : AudioPluginInfoModifier {
private val metadataStore: JellyfinMetadataStore,
private val config: JellyfinConfig,
) : AudioPluginInfoModifier {
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? { override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
val uri = track.info.uri ?: return null if (track !is JellyfinAudioTrack) {
if (!uri.startsWith(config.baseUrl.trimEnd('/'))) {
return null return null
} }
val meta = metadataStore.get(uri) ?: return null val metadata = track.info
val map = buildMap<String, JsonPrimitive> { val map = buildMap {
meta.id.let { put("jellyfinId", JsonPrimitive(it)) } metadata.identifier.let { put("jellyfinId", JsonPrimitive(it)) }
meta.title?.let { put("jellyfinTitle", JsonPrimitive(it)) } metadata.title?.let { put("name", JsonPrimitive(it)) }
meta.artist?.let { put("jellyfinArtist", JsonPrimitive(it)) } metadata.author?.let { put("artist", JsonPrimitive(it)) }
meta.album?.let { put("jellyfinAlbum", JsonPrimitive(it)) } track.album?.let { put("albumName", JsonPrimitive(it)) }
meta.lengthMs?.let { put("jellyfinLengthMs", JsonPrimitive(it)) } metadata.length.let { put("length", JsonPrimitive(it)) }
meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) } metadata.artworkUrl?.let { put("artistArtworkUrl", JsonPrimitive(it)) }
} }
return if (map.isEmpty()) { return if (map.isEmpty()) {

View File

@@ -3,6 +3,7 @@ package dev.jellylink.jellyfin.audio
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
import com.sedmelluq.discord.lavaplayer.track.AudioItem import com.sedmelluq.discord.lavaplayer.track.AudioItem
@@ -10,24 +11,20 @@ import com.sedmelluq.discord.lavaplayer.track.AudioReference
import com.sedmelluq.discord.lavaplayer.track.AudioTrack import com.sedmelluq.discord.lavaplayer.track.AudioTrack
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
import dev.jellylink.jellyfin.client.JellyfinApiClient import dev.jellylink.jellyfin.client.JellyfinApiClient
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.DataInput import java.io.DataInput
import java.io.DataInputStream
import java.io.DataOutput import java.io.DataOutput
import java.io.IOException import java.io.IOException
/** /**
* Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers * Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers
* against a Jellyfin server. * 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 @Service
class JellyfinAudioSourceManager( class JellyfinAudioSourceManager(
private val apiClient: JellyfinApiClient, private val apiClient: JellyfinApiClient,
private val metadataStore: JellyfinMetadataStore,
) : AudioSourceManager { ) : AudioSourceManager {
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager() private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
@@ -51,6 +48,7 @@ class JellyfinAudioSourceManager(
if (!apiClient.ensureAuthenticated()) { if (!apiClient.ensureAuthenticated()) {
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.") log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
return null return null
} }
@@ -64,14 +62,15 @@ class JellyfinAudioSourceManager(
if (item == null) { if (item == null) {
log.warn("No Jellyfin results found for query: {}", query) log.warn("No Jellyfin results found for query: {}", query)
return null return null
} }
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id) log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
val playbackUrl = apiClient.buildPlaybackUrl(item.id) val playbackUrl = apiClient.buildPlaybackUrl(item.id)
log.info("Jellyfin playback URL: {}", playbackUrl)
metadataStore.put(playbackUrl, item) log.info("Jellyfin playback URL: {}", playbackUrl)
val trackInfo = val trackInfo =
AudioTrackInfo( AudioTrackInfo(
@@ -85,7 +84,7 @@ class JellyfinAudioSourceManager(
null, null,
) )
return JellyfinAudioTrack(trackInfo, this) return JellyfinAudioTrack(trackInfo, item.album, this)
} }
override fun isTrackEncodable(track: AudioTrack): Boolean = true override fun isTrackEncodable(track: AudioTrack): Boolean = true
@@ -95,17 +94,30 @@ class JellyfinAudioSourceManager(
track: AudioTrack, track: AudioTrack,
output: DataOutput, output: DataOutput,
) { ) {
// No additional data to encode beyond AudioTrackInfo. val jfTrack = track as JellyfinAudioTrack
DataFormatTools.writeNullableText(output, jfTrack.album)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun decodeTrack( override fun decodeTrack(
trackInfo: AudioTrackInfo, trackInfo: AudioTrackInfo,
input: DataInput, input: DataInput,
): AudioTrack = JellyfinAudioTrack(trackInfo, this) ): AudioTrack {
var album: String? = null
if ((input as DataInputStream).available() > 0) {
album = DataFormatTools.readNullableText(input)
}
return JellyfinAudioTrack(trackInfo, album, this)
}
override fun shutdown() { override fun shutdown() {
httpInterfaceManager.close() this.httpInterfaceManager.close()
}
override fun toString(): String {
return "Jellylink - Source manager for Jellyfin by Myxelium"
} }
companion object { companion object {

View File

@@ -16,6 +16,7 @@ import java.net.URI
class JellyfinAudioTrack( class JellyfinAudioTrack(
trackInfo: AudioTrackInfo, trackInfo: AudioTrackInfo,
val album: String?,
private val sourceManager: JellyfinAudioSourceManager, private val sourceManager: JellyfinAudioSourceManager,
) : DelegatedAudioTrack(trackInfo) { ) : DelegatedAudioTrack(trackInfo) {
@Throws(Exception::class) @Throws(Exception::class)
@@ -58,7 +59,7 @@ class JellyfinAudioTrack(
} }
} }
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager) override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, album, sourceManager)
override fun getSourceManager(): AudioSourceManager = sourceManager override fun getSourceManager(): AudioSourceManager = sourceManager

View File

@@ -9,119 +9,21 @@ 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
/**
* 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 @Component
class JellyfinApiClient( class JellyfinApiClient(
private val config: JellyfinConfig, private val config: JellyfinConfig,
private val authenticator: JellyfinAuthenticator,
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(), 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. * Delegate to [JellyfinAuthenticator.ensureAuthenticated].
*
* @return `true` when a valid token is ready for use
*/ */
fun ensureAuthenticated(): Boolean { fun ensureAuthenticated(): Boolean = authenticator.ensureAuthenticated()
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]. * Search Jellyfin for the first audio item matching [query].
* *
* Handles 401 retry transparently.
*
* @return parsed [JellyfinMetadata], or `null` if no result / error * @return parsed [JellyfinMetadata], or `null` if no result / error
*/ */
fun searchFirstAudioItem(query: String): JellyfinMetadata? { fun searchFirstAudioItem(query: String): JellyfinMetadata? {
@@ -158,14 +60,15 @@ class JellyfinApiClient(
if (response.statusCode() == HTTP_UNAUTHORIZED) { if (response.statusCode() == HTTP_UNAUTHORIZED) {
log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating") log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating")
invalidateToken() authenticator.invalidateToken()
if (!ensureAuthenticated()) { if (!authenticator.ensureAuthenticated()) {
log.error("Jellyfin re-authentication failed after 401") log.error("Jellyfin re-authentication failed after 401")
return null return null
} }
val retryRequest = buildGetRequest(url) ?: return null val retryRequest = buildGetRequest(url) ?: return null
response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString()) response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString())
} }
@@ -173,7 +76,7 @@ class JellyfinApiClient(
} }
private fun buildGetRequest(url: String): HttpRequest? { private fun buildGetRequest(url: String): HttpRequest? {
val token = accessToken ?: return null val token = authenticator.accessToken ?: return null
return HttpRequest return HttpRequest
.newBuilder() .newBuilder()
@@ -183,16 +86,12 @@ class JellyfinApiClient(
.build() .build()
} }
// -----------------------------------------------------------------------
// Playback URL
// -----------------------------------------------------------------------
/** /**
* Build a streaming URL for the given Jellyfin item, respecting audio quality settings. * Build a streaming URL for the given Jellyfin item, respecting audio quality settings.
*/ */
fun buildPlaybackUrl(itemId: String): String { fun buildPlaybackUrl(itemId: String): String {
val base = config.baseUrl.trimEnd('/') val base = config.baseUrl.trimEnd('/')
val token = accessToken ?: "" val token = authenticator.accessToken ?: ""
val quality = config.audioQuality.trim().uppercase() val quality = config.audioQuality.trim().uppercase()
if (quality == "ORIGINAL") { if (quality == "ORIGINAL") {
@@ -219,12 +118,6 @@ class JellyfinApiClient(
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token" return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
} }
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private fun escape(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
companion object { companion object {
private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java) private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java)
private val httpClient: HttpClient = HttpClient.newHttpClient() private val httpClient: HttpClient = HttpClient.newHttpClient()
@@ -234,7 +127,6 @@ class JellyfinApiClient(
private const val ERROR_BODY_PREVIEW_LENGTH = 500 private const val ERROR_BODY_PREVIEW_LENGTH = 500
private const val DEBUG_BODY_PREVIEW_LENGTH = 2000 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_HIGH = 320_000
private const val BITRATE_MEDIUM = 192_000 private const val BITRATE_MEDIUM = 192_000
private const val BITRATE_LOW = 128_000 private const val BITRATE_LOW = 128_000

View File

@@ -0,0 +1,148 @@
package dev.jellylink.jellyfin.client
import dev.jellylink.jellyfin.config.JellyfinConfig
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Instant
import java.util.UUID
/**
* Handles Jellyfin authentication and token lifecycle.
*
* Maintains the current access token, user ID, and automatic expiration /
* re-authentication logic.
*/
@Component
class JellyfinAuthenticator(
private val config: JellyfinConfig,
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(),
) {
@Volatile
var accessToken: String? = null
private set
@Volatile
var userId: String? = null
private set
@Volatile
private var tokenObtainedAt: Instant? = null
/**
* 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 = buildString {
append(config.baseUrl.trimEnd('/'))
append("/Users/AuthenticateByName")
}
val body = """{"Username":"${escape(config.username)}","Pw":"${escape(config.password)}"}"""
val deviceId = UUID.randomUUID()
val version = getVersionNumber()
val authHeader = buildString {
append("MediaBrowser ")
append("Client=\"Jellylink\", ")
append("Device=\"Lavalink\", ")
append("DeviceId=\"$deviceId\", ")
append("Version=\"$version\"")
}
val request = buildAuthenticatonRequest(url, authHeader, body)
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
}
private fun getVersionNumber(): String {
return this::class.java.getPackage().implementationVersion ?: "unknown"
}
private fun buildAuthenticatonRequest(
url: String,
authHeader: String,
body: String
): HttpRequest? {
return HttpRequest
.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", authHeader)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
}
private fun escape(value: String): String {
return value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
}
companion object {
private val log = LoggerFactory.getLogger(JellyfinAuthenticator::class.java)
private val httpClient: HttpClient = HttpClient.newHttpClient()
private val HTTP_OK_RANGE = 200..299
private const val ERROR_BODY_PREVIEW_LENGTH = 500
private const val SECONDS_PER_MINUTE = 60L
}
}

View File

@@ -1,18 +0,0 @@
package dev.jellylink.jellyfin.model
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
@Component
class JellyfinMetadataStore {
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
fun put(
url: String,
metadata: JellyfinMetadata,
) {
data[url] = metadata
}
fun get(url: String): JellyfinMetadata? = data[url]
}