Minor refactoring and cleaning

This commit is contained in:
2026-02-16 23:25:52 +01:00
parent d8ee67c2df
commit 7bb1725dc0
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-declaration-site = disabled
ktlint_standard_multiline-expression-wrapping = disabled
ktlint_standard_function-expression-body = disabled

View File

@@ -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<io.gitlab.arturbosch.detekt.Detekt>().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")
}

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:
# 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)
# ---------------------------------------------------------------------------

View File

@@ -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<String, JsonPrimitive> {
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()) {

View File

@@ -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:<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()
@@ -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 {

View File

@@ -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

View File

@@ -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

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]
}