From 7bb1725dc026e517ebd4cbd52c3eaffcaeb8c77c Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 16 Feb 2026 23:25:52 +0100 Subject: [PATCH] Minor refactoring and cleaning --- .editorconfig | 1 + build.gradle.kts | 11 +- detekt.yml | 36 +---- .../audio/JellyfinAudioPluginInfoModifier.kt | 27 ++-- .../audio/JellyfinAudioSourceManager.kt | 34 ++-- .../jellyfin/audio/JellyfinAudioTrack.kt | 3 +- .../jellyfin/client/JellyfinApiClient.kt | 124 +-------------- .../jellyfin/client/JellyfinAuthenticator.kt | 148 ++++++++++++++++++ .../jellyfin/model/JellyfinMetadataStore.kt | 18 --- 9 files changed, 196 insertions(+), 206 deletions(-) create mode 100644 src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinAuthenticator.kt delete mode 100644 src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt diff --git a/.editorconfig b/.editorconfig index 5359bcb..01e4e85 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,3 +17,4 @@ ktlint_standard_no-consecutive-blank-lines = enabled ktlint_standard_trailing-comma-on-call-site = disabled ktlint_standard_trailing-comma-on-declaration-site = disabled ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_function-expression-body = disabled diff --git a/build.gradle.kts b/build.gradle.kts index 3c7a75b..31649c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "dev.jellylink" -version = "0.2.0" +version = "0.2.1" lavalinkPlugin { name = "jellylink-jellyfin" @@ -25,9 +25,6 @@ kotlin { jvmToolchain(17) } -// --------------------------------------------------------------------------- -// Detekt — static analysis -// --------------------------------------------------------------------------- detekt { config.setFrom(files("$rootDir/detekt.yml")) buildUponDefaultConfig = true @@ -47,9 +44,6 @@ tasks.withType().configureEach { exclude("**/generated/**") } -// --------------------------------------------------------------------------- -// ktlint — formatting -// --------------------------------------------------------------------------- ktlint { version.set("1.5.0") android.set(false) @@ -60,9 +54,6 @@ ktlint { } } -// --------------------------------------------------------------------------- -// Wire lint checks into the build lifecycle -// --------------------------------------------------------------------------- tasks.named("check") { dependsOn("detekt", "ktlintCheck") } diff --git a/detekt.yml b/detekt.yml index 212ecf7..4bf4e73 100644 --- a/detekt.yml +++ b/detekt.yml @@ -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: # Cyclomatic complexity per function — matches ESLint complexity: 20 CyclomaticComplexMethod: @@ -47,9 +38,6 @@ complexity: active: true threshold: 80 -# --------------------------------------------------------------------------- -# Empty blocks — no empty functions or classes -# --------------------------------------------------------------------------- empty-blocks: EmptyFunctionBlock: active: true @@ -71,9 +59,6 @@ empty-blocks: EmptyWhenBlock: active: true -# --------------------------------------------------------------------------- -# Naming conventions — forbid short / misleading identifiers -# --------------------------------------------------------------------------- naming: ForbiddenClassName: active: false @@ -91,10 +76,10 @@ naming: TopLevelPropertyNaming: active: true -# --------------------------------------------------------------------------- -# Style rules — val preference, visibility, imports, member ordering -# --------------------------------------------------------------------------- style: + ExpressionBodySyntax: + active: false + # Allow guard-clause heavy functions (early returns are idiomatic Kotlin) ReturnCount: active: true @@ -153,8 +138,6 @@ style: excludePackageStatements: true excludeImportStatements: true - - # Prefer expression body for simple single-expression functions OptionalAbstractKeyword: active: true @@ -168,9 +151,6 @@ style: singleLine: always multiLine: always -# --------------------------------------------------------------------------- -# Potential bugs -# --------------------------------------------------------------------------- potential-bugs: EqualsAlwaysReturnsTrueOrFalse: active: true @@ -181,9 +161,6 @@ potential-bugs: IteratorNotThrowingNoSuchElementException: active: true -# --------------------------------------------------------------------------- -# Performance -# --------------------------------------------------------------------------- performance: SpreadOperator: active: false @@ -191,9 +168,6 @@ performance: ForEachOnRange: active: true -# --------------------------------------------------------------------------- -# Exceptions -# --------------------------------------------------------------------------- exceptions: TooGenericExceptionCaught: active: true @@ -220,7 +194,3 @@ exceptions: - "MalformedURLException" - "NumberFormatException" - "ParseException" - -# --------------------------------------------------------------------------- -# Global exclusions — handled in build.gradle.kts (source.setFrom, exclude) -# --------------------------------------------------------------------------- diff --git a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt index 9fc1113..db9a6e5 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt @@ -2,33 +2,26 @@ 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 @Component -class JellyfinAudioPluginInfoModifier( - private val metadataStore: JellyfinMetadataStore, - private val config: JellyfinConfig, -) : AudioPluginInfoModifier { +class JellyfinAudioPluginInfoModifier : AudioPluginInfoModifier { override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? { - val uri = track.info.uri ?: return null - - if (!uri.startsWith(config.baseUrl.trimEnd('/'))) { + if (track !is JellyfinAudioTrack) { return null } - val meta = metadataStore.get(uri) ?: return null + val metadata = track.info - val map = buildMap { - meta.id.let { put("jellyfinId", JsonPrimitive(it)) } - meta.title?.let { put("jellyfinTitle", JsonPrimitive(it)) } - meta.artist?.let { put("jellyfinArtist", JsonPrimitive(it)) } - meta.album?.let { put("jellyfinAlbum", JsonPrimitive(it)) } - meta.lengthMs?.let { put("jellyfinLengthMs", JsonPrimitive(it)) } - meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) } + val map = buildMap { + metadata.identifier.let { put("jellyfinId", JsonPrimitive(it)) } + metadata.title?.let { put("name", JsonPrimitive(it)) } + metadata.author?.let { put("artist", JsonPrimitive(it)) } + track.album?.let { put("albumName", JsonPrimitive(it)) } + metadata.length.let { put("length", JsonPrimitive(it)) } + metadata.artworkUrl?.let { put("artistArtworkUrl", JsonPrimitive(it)) } } return if (map.isEmpty()) { diff --git a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt index 8e4622b..d18f261 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt @@ -3,6 +3,7 @@ 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.DataFormatTools import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface 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.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.DataInputStream import java.io.DataOutput import java.io.IOException /** * Lavaplayer [AudioSourceManager] that resolves `jfsearch:` 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() @@ -51,6 +48,7 @@ class JellyfinAudioSourceManager( if (!apiClient.ensureAuthenticated()) { log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.") + return null } @@ -64,14 +62,15 @@ class JellyfinAudioSourceManager( 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) + log.info("Jellyfin playback URL: {}", playbackUrl) val trackInfo = AudioTrackInfo( @@ -85,7 +84,7 @@ class JellyfinAudioSourceManager( null, ) - return JellyfinAudioTrack(trackInfo, this) + return JellyfinAudioTrack(trackInfo, item.album, this) } override fun isTrackEncodable(track: AudioTrack): Boolean = true @@ -95,17 +94,30 @@ class JellyfinAudioSourceManager( track: AudioTrack, output: DataOutput, ) { - // No additional data to encode beyond AudioTrackInfo. + val jfTrack = track as JellyfinAudioTrack + DataFormatTools.writeNullableText(output, jfTrack.album) } @Throws(IOException::class) override fun decodeTrack( trackInfo: AudioTrackInfo, 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() { - httpInterfaceManager.close() + this.httpInterfaceManager.close() + } + + override fun toString(): String { + return "Jellylink - Source manager for Jellyfin by Myxelium" } companion object { diff --git a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt index f585937..e5157a6 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt @@ -16,6 +16,7 @@ import java.net.URI class JellyfinAudioTrack( trackInfo: AudioTrackInfo, + val album: String?, private val sourceManager: JellyfinAudioSourceManager, ) : DelegatedAudioTrack(trackInfo) { @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 diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt index f73fb37..712cbe8 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt @@ -9,119 +9,21 @@ 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 authenticator: JellyfinAuthenticator, 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 + * Delegate to [JellyfinAuthenticator.ensureAuthenticated]. */ - 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 - // ----------------------------------------------------------------------- + fun ensureAuthenticated(): Boolean = authenticator.ensureAuthenticated() /** * 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? { @@ -158,14 +60,15 @@ class JellyfinApiClient( if (response.statusCode() == HTTP_UNAUTHORIZED) { 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") return null } val retryRequest = buildGetRequest(url) ?: return null + response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString()) } @@ -173,7 +76,7 @@ class JellyfinApiClient( } private fun buildGetRequest(url: String): HttpRequest? { - val token = accessToken ?: return null + val token = authenticator.accessToken ?: return null return HttpRequest .newBuilder() @@ -183,16 +86,12 @@ class JellyfinApiClient( .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 token = authenticator.accessToken ?: "" val quality = config.audioQuality.trim().uppercase() if (quality == "ORIGINAL") { @@ -219,12 +118,6 @@ class JellyfinApiClient( 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() @@ -234,7 +127,6 @@ class JellyfinApiClient( 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 diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinAuthenticator.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinAuthenticator.kt new file mode 100644 index 0000000..0876bf3 --- /dev/null +++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinAuthenticator.kt @@ -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 + } +} diff --git a/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt deleted file mode 100644 index f950d4b..0000000 --- a/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt +++ /dev/null @@ -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() - - fun put( - url: String, - metadata: JellyfinMetadata, - ) { - data[url] = metadata - } - - fun get(url: String): JellyfinMetadata? = data[url] -}