From a71cced278bb72a6b19522fb07b20df975af1b4b Mon Sep 17 00:00:00 2001 From: Myx Date: Sun, 15 Feb 2026 19:05:23 +0100 Subject: [PATCH] Add linter --- .editorconfig | 19 ++ build.gradle.kts | 44 +++ detekt.yml | 226 +++++++++++++++ gradle/libs.versions.toml | 4 + .../jellyfin/JellyfinAudioSourceManager.kt | 272 ------------------ .../JellyfinAudioPluginInfoModifier.kt | 18 +- .../audio/JellyfinAudioSourceManager.kt | 115 ++++++++ .../{ => audio}/JellyfinAudioTrack.kt | 35 +-- .../jellyfin/client/JellyfinApiClient.kt | 243 ++++++++++++++++ .../jellyfin/client/JellyfinResponseParser.kt | 123 ++++++++ .../jellyfin/{ => config}/JellyfinConfig.kt | 2 +- .../jellyfin/model/JellyfinMetadata.kt | 10 + .../{ => model}/JellyfinMetadataStore.kt | 16 +- 13 files changed, 821 insertions(+), 306 deletions(-) create mode 100644 .editorconfig create mode 100644 detekt.yml delete mode 100644 src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt rename src/main/kotlin/dev/jellylink/jellyfin/{ => audio}/JellyfinAudioPluginInfoModifier.kt (73%) create mode 100644 src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt rename src/main/kotlin/dev/jellylink/jellyfin/{ => audio}/JellyfinAudioTrack.kt (81%) create mode 100644 src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt create mode 100644 src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt rename src/main/kotlin/dev/jellylink/jellyfin/{ => config}/JellyfinConfig.kt (97%) create mode 100644 src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt rename src/main/kotlin/dev/jellylink/jellyfin/{ => model}/JellyfinMetadataStore.kt (53%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5359bcb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{kt,kts}] +max_line_length = 150 +ktlint_standard_no-wildcard-imports = enabled +ktlint_standard_no-multi-spaces = enabled +ktlint_standard_no-trailing-spaces = enabled +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 diff --git a/build.gradle.kts b/build.gradle.kts index 7de4f98..3965f0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,8 @@ plugins { kotlin("jvm") version "1.8.22" alias(libs.plugins.lavalink) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) } group = "dev.jellylink" @@ -22,3 +24,45 @@ java { kotlin { jvmToolchain(17) } + +// --------------------------------------------------------------------------- +// Detekt — static analysis +// --------------------------------------------------------------------------- +detekt { + config.setFrom(files("$rootDir/detekt.yml")) + buildUponDefaultConfig = true + allRules = false + parallel = true + // Exclude generated code from analysis + source.setFrom(files("src/main/kotlin")) +} + +tasks.withType().configureEach { + reports { + html.required.set(true) + xml.required.set(false) + txt.required.set(false) + sarif.required.set(false) + } + exclude("**/generated/**") +} + +// --------------------------------------------------------------------------- +// ktlint — formatting +// --------------------------------------------------------------------------- +ktlint { + version.set("1.5.0") + android.set(false) + outputToConsole.set(true) + ignoreFailures.set(false) + filter { + exclude("**/generated/**") + } +} + +// --------------------------------------------------------------------------- +// Wire lint checks into the build lifecycle +// --------------------------------------------------------------------------- +tasks.named("check") { + dependsOn("detekt", "ktlintCheck") +} diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..212ecf7 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,226 @@ +# ============================================================================= +# 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: + active: true + threshold: 20 + + # Flag classes with too many lines of code + LargeClass: + active: true + threshold: 600 + + # Flag files/classes with too many functions + TooManyFunctions: + active: true + thresholdInFiles: 30 + thresholdInClasses: 25 + thresholdInInterfaces: 20 + thresholdInObjects: 20 + thresholdInEnums: 10 + ignoreOverridden: true + + # Flag deeply nested blocks + NestedBlockDepth: + active: true + threshold: 5 + + # Flag functions with too many parameters + LongParameterList: + active: true + functionThreshold: 8 + constructorThreshold: 10 + ignoreDefaultParameters: true + ignoreAnnotated: + - "ConfigurationProperties" + + # Flag excessively long methods + LongMethod: + active: true + threshold: 80 + +# --------------------------------------------------------------------------- +# Empty blocks — no empty functions or classes +# --------------------------------------------------------------------------- +empty-blocks: + EmptyFunctionBlock: + active: true + ignoreOverridden: true + + EmptyClassBlock: + active: true + + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: "_|(ignore|expected).*" + + EmptyElseBlock: + active: true + + EmptyIfBlock: + active: true + + EmptyWhenBlock: + active: true + +# --------------------------------------------------------------------------- +# Naming conventions — forbid short / misleading identifiers +# --------------------------------------------------------------------------- +naming: + ForbiddenClassName: + active: false + + FunctionNaming: + active: true + + # Forbid single-letter and overly generic variable names + # Matches: e, i, x, y, any, string + VariableNaming: + active: true + variablePattern: "(?!^(e|i|x|y|any|string)$)[a-z][A-Za-z0-9]*" + + # Top-level / companion object properties must follow convention + TopLevelPropertyNaming: + active: true + +# --------------------------------------------------------------------------- +# Style rules — val preference, visibility, imports, member ordering +# --------------------------------------------------------------------------- +style: + # Allow guard-clause heavy functions (early returns are idiomatic Kotlin) + ReturnCount: + active: true + max: 8 + excludeGuardClauses: true + + # Prefer val over var when variable is never reassigned + VarCouldBeVal: + active: true + ignoreAnnotated: + - "ConfigurationProperties" + + # No wildcard imports (reinforces ktlint rule) + WildcardImport: + active: true + excludeImports: [] + + # Forbid specific imports (configurable — add patterns as needed) + ForbiddenImport: + active: true + imports: [] + forbiddenPatterns: "" + + # Flag unused private members + UnusedPrivateMember: + active: true + allowedNames: "(_|ignored|expected|serialVersionUID)" + + # Flag magic numbers + MagicNumber: + active: true + ignoreNumbers: + - "-1" + - "0" + - "1" + - "2" + - "10" + - "100" + - "1000" + ignoreHashCodeFunction: true + ignorePropertyDeclaration: true + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: true + ignoreNamedArgument: true + ignoreEnums: true + ignoreAnnotated: + - "ConfigurationProperties" + + # Max line length — matches ktlint / editorconfig value + MaxLineLength: + active: true + maxLineLength: 150 + excludeCommentStatements: true + excludePackageStatements: true + excludeImportStatements: true + + + # Prefer expression body for simple single-expression functions + OptionalAbstractKeyword: + active: true + + # Enforce consistent member ordering inside classes + ClassOrdering: + active: true + + # Enforce braces on all if/else branches — no single-line if bodies + BracesOnIfStatements: + active: true + singleLine: always + multiLine: always + +# --------------------------------------------------------------------------- +# Potential bugs +# --------------------------------------------------------------------------- +potential-bugs: + EqualsAlwaysReturnsTrueOrFalse: + active: true + + UnreachableCode: + active: true + + IteratorNotThrowingNoSuchElementException: + active: true + +# --------------------------------------------------------------------------- +# Performance +# --------------------------------------------------------------------------- +performance: + SpreadOperator: + active: false + + ForEachOnRange: + active: true + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- +exceptions: + TooGenericExceptionCaught: + active: true + exceptionNames: + - "ArrayIndexOutOfBoundsException" + - "Error" + - "IllegalMonitorStateException" + - "IndexOutOfBoundsException" + - "NullPointerException" + - "RuntimeException" + + TooGenericExceptionThrown: + active: true + exceptionNames: + - "Error" + - "Exception" + - "RuntimeException" + - "Throwable" + + SwallowedException: + active: true + ignoredExceptionTypes: + - "InterruptedException" + - "MalformedURLException" + - "NumberFormatException" + - "ParseException" + +# --------------------------------------------------------------------------- +# Global exclusions — handled in build.gradle.kts (source.setFrom, exclude) +# --------------------------------------------------------------------------- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1f94cf..026eb86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,10 @@ [versions] lavalink-api = "4.0.8" lavalink-server = "4.0.8" +detekt = "1.23.7" +ktlint = "12.1.2" [plugins] lavalink = { id = "dev.arbjerg.lavalink.gradle-plugin", version = "1.0.15" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt deleted file mode 100644 index e38f5b1..0000000 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt +++ /dev/null @@ -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() - } -} diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt similarity index 73% rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt rename to src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt index 26045a4..9fc1113 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt @@ -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) + } } } diff --git a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt new file mode 100644 index 0000000..8e4622b --- /dev/null +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt @@ -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:` 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:" + } +} diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt similarity index 81% rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt rename to src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt index 1690582..f585937 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt @@ -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) + } } diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt new file mode 100644 index 0000000..f73fb37 --- /dev/null +++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt @@ -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? { + 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 + } +} diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt new file mode 100644 index 0000000..ac36509 --- /dev/null +++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt @@ -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 + } +} diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt b/src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt similarity index 97% rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt rename to src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt index d6ec9dd..39fa063 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt @@ -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 diff --git a/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt new file mode 100644 index 0000000..65d25e3 --- /dev/null +++ b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt @@ -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?, +) diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt similarity index 53% rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt rename to src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt index 59f1b66..f950d4b 100644 --- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt +++ b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt @@ -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() - fun put(url: String, metadata: JellyfinMetadata) { + fun put( + url: String, + metadata: JellyfinMetadata, + ) { data[url] = metadata }