Add linter

This commit is contained in:
2026-02-15 19:05:23 +01:00
parent 7c6e96b1a2
commit a71cced278
13 changed files with 821 additions and 306 deletions

19
.editorconfig Normal file
View File

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

View File

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

226
detekt.yml Normal file
View File

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

View File

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

View File

@@ -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()
}
}

View File

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

View File

@@ -0,0 +1,115 @@
package dev.jellylink.jellyfin.audio
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
import com.sedmelluq.discord.lavaplayer.track.AudioItem
import com.sedmelluq.discord.lavaplayer.track.AudioReference
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
import dev.jellylink.jellyfin.client.JellyfinApiClient
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.DataInput
import java.io.DataOutput
import java.io.IOException
/**
* Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers
* against a Jellyfin server.
*
* This class is intentionally thin — it owns the Lavaplayer contract and delegates
* HTTP / parsing work to [JellyfinApiClient] and [dev.jellylink.jellyfin.client.JellyfinResponseParser].
*/
@Service
class JellyfinAudioSourceManager(
private val apiClient: JellyfinApiClient,
private val metadataStore: JellyfinMetadataStore,
) : AudioSourceManager {
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY
fun getHttpInterface(): HttpInterface = httpInterfaceManager.`interface`
override fun getSourceName(): String = "jellyfin"
override fun loadItem(
manager: AudioPlayerManager,
reference: AudioReference,
): AudioItem? {
val identifier = reference.identifier ?: return null
if (!identifier.startsWith(SEARCH_PREFIX, ignoreCase = true)) {
return null
}
log.info("Jellyfin source handling identifier: {}", identifier)
if (!apiClient.ensureAuthenticated()) {
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
return null
}
val query = identifier.substring(SEARCH_PREFIX.length).trim()
if (query.isEmpty()) {
return null
}
val item = apiClient.searchFirstAudioItem(query)
if (item == null) {
log.warn("No Jellyfin results found for query: {}", query)
return null
}
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
val playbackUrl = apiClient.buildPlaybackUrl(item.id)
log.info("Jellyfin playback URL: {}", playbackUrl)
metadataStore.put(playbackUrl, item)
val trackInfo =
AudioTrackInfo(
item.title ?: "Unknown",
item.artist ?: "Unknown",
item.lengthMs ?: Long.MAX_VALUE,
item.id,
false,
playbackUrl,
item.artworkUrl,
null,
)
return JellyfinAudioTrack(trackInfo, this)
}
override fun isTrackEncodable(track: AudioTrack): Boolean = true
@Throws(IOException::class)
override fun encodeTrack(
track: AudioTrack,
output: DataOutput,
) {
// No additional data to encode beyond AudioTrackInfo.
}
@Throws(IOException::class)
override fun decodeTrack(
trackInfo: AudioTrackInfo,
input: DataInput,
): AudioTrack = JellyfinAudioTrack(trackInfo, this)
override fun shutdown() {
httpInterfaceManager.close()
}
companion object {
private val log = LoggerFactory.getLogger(JellyfinAudioSourceManager::class.java)
private const val SEARCH_PREFIX = "jfsearch:"
}
}

View File

@@ -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(
val result =
MediaContainerDetection(
sourceManager.containerRegistry,
AudioReference(trackInfo.uri, trackInfo.title),
stream,
MediaContainerHints.from(null, null)
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)
}
}

View File

@@ -0,0 +1,243 @@
package dev.jellylink.jellyfin.client
import dev.jellylink.jellyfin.config.JellyfinConfig
import dev.jellylink.jellyfin.model.JellyfinMetadata
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.time.Instant
import java.util.UUID
/**
* Handles all HTTP communication with the Jellyfin server.
*
* Responsibilities:
* - Authentication (login, token refresh, invalidation)
* - Sending search requests (with automatic 401 retry)
* - Building audio playback URLs
*/
@Component
class JellyfinApiClient(
private val config: JellyfinConfig,
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(),
) {
@Volatile
var accessToken: String? = null
private set
@Volatile
private var userId: String? = null
@Volatile
private var tokenObtainedAt: Instant? = null
// -----------------------------------------------------------------------
// Authentication
// -----------------------------------------------------------------------
/**
* Ensure a valid access token is available, authenticating if necessary.
*
* @return `true` when a valid token is ready for use
*/
fun ensureAuthenticated(): Boolean {
if (accessToken != null && userId != null && !isTokenExpired()) {
return true
}
if (accessToken != null && isTokenExpired()) {
log.info("Jellyfin access token expired after {} minutes, re-authenticating", config.tokenRefreshMinutes)
invalidateToken()
}
if (config.baseUrl.isBlank() || config.username.isBlank() || config.password.isBlank()) {
return false
}
return authenticate()
}
/**
* Invalidate the current token so the next call will re-authenticate.
*/
fun invalidateToken() {
log.info("Invalidating Jellyfin access token")
accessToken = null
userId = null
tokenObtainedAt = null
}
private fun isTokenExpired(): Boolean {
val refreshMinutes = config.tokenRefreshMinutes
if (refreshMinutes <= 0) {
return false
}
val obtainedAt = tokenObtainedAt ?: return true
return Instant.now().isAfter(obtainedAt.plusSeconds(refreshMinutes * SECONDS_PER_MINUTE))
}
private fun authenticate(): Boolean {
val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName"
val body = """{"Username":"${escape(config.username)}","Pw":"${escape(config.password)}"}"""
val authHeader = "MediaBrowser Client=\"Jellylink\", Device=\"Lavalink\", DeviceId=\"${UUID.randomUUID()}\", Version=\"0.1.0\""
val request = HttpRequest
.newBuilder()
.uri(java.net.URI.create(url))
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", authHeader)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() !in HTTP_OK_RANGE) {
log.error("Jellyfin auth failed with status {}: {}", response.statusCode(), response.body().take(ERROR_BODY_PREVIEW_LENGTH))
return false
}
log.info("Successfully authenticated with Jellyfin")
val result = responseParser.parseAuthResponse(response.body()) ?: return false
accessToken = result.accessToken
userId = result.userId
tokenObtainedAt = Instant.now()
return true
}
// -----------------------------------------------------------------------
// Search
// -----------------------------------------------------------------------
/**
* Search Jellyfin for the first audio item matching [query].
*
* Handles 401 retry transparently.
*
* @return parsed [JellyfinMetadata], or `null` if no result / error
*/
fun searchFirstAudioItem(query: String): JellyfinMetadata? {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8)
val url = StringBuilder()
.append(config.baseUrl.trimEnd('/'))
.append("/Items?SearchTerm=")
.append(encodedQuery)
.append("&IncludeItemTypes=Audio&Recursive=true&Limit=")
.append(config.searchLimit)
.append("&Fields=Artists,AlbumArtist,MediaSources,ImageTags")
.toString()
val response = executeGetWithRetry(url) ?: return null
if (response.statusCode() !in HTTP_OK_RANGE) {
log.error("Jellyfin search failed with status {}: {}", response.statusCode(), response.body().take(ERROR_BODY_PREVIEW_LENGTH))
return null
}
val body = response.body()
log.debug("Jellyfin search response: {}", body.take(DEBUG_BODY_PREVIEW_LENGTH))
return responseParser.parseFirstAudioItem(body, config.baseUrl)
}
/**
* Execute a GET request, retrying once on 401 (server-side token revocation).
*/
private fun executeGetWithRetry(url: String): HttpResponse<String>? {
val request = buildGetRequest(url) ?: return null
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == HTTP_UNAUTHORIZED) {
log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating")
invalidateToken()
if (!ensureAuthenticated()) {
log.error("Jellyfin re-authentication failed after 401")
return null
}
val retryRequest = buildGetRequest(url) ?: return null
response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString())
}
return response
}
private fun buildGetRequest(url: String): HttpRequest? {
val token = accessToken ?: return null
return HttpRequest
.newBuilder()
.uri(java.net.URI.create(url))
.header("X-Emby-Token", token)
.GET()
.build()
}
// -----------------------------------------------------------------------
// Playback URL
// -----------------------------------------------------------------------
/**
* Build a streaming URL for the given Jellyfin item, respecting audio quality settings.
*/
fun buildPlaybackUrl(itemId: String): String {
val base = config.baseUrl.trimEnd('/')
val token = accessToken ?: ""
val quality = config.audioQuality.trim().uppercase()
if (quality == "ORIGINAL") {
return "$base/Audio/$itemId/stream?static=true&api_key=$token"
}
val bitrate =
when (quality) {
"HIGH" -> BITRATE_HIGH
"MEDIUM" -> BITRATE_MEDIUM
"LOW" -> BITRATE_LOW
else -> {
val custom = config.audioQuality.trim().toIntOrNull()
if (custom != null) {
custom * KBPS_TO_BPS
} else {
BITRATE_HIGH
}
}
}
val codec = config.audioCodec.trim().ifEmpty { "mp3" }
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private fun escape(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
companion object {
private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java)
private val httpClient: HttpClient = HttpClient.newHttpClient()
private val HTTP_OK_RANGE = 200..299
private const val HTTP_UNAUTHORIZED = 401
private const val ERROR_BODY_PREVIEW_LENGTH = 500
private const val DEBUG_BODY_PREVIEW_LENGTH = 2000
private const val SECONDS_PER_MINUTE = 60L
private const val BITRATE_HIGH = 320_000
private const val BITRATE_MEDIUM = 192_000
private const val BITRATE_LOW = 128_000
private const val KBPS_TO_BPS = 1000
}
}

View File

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

View File

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

View File

@@ -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?,
)

View File

@@ -1,22 +1,16 @@
package dev.jellylink.jellyfin
package dev.jellylink.jellyfin.model
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
data class JellyfinMetadata(
val id: String,
val title: String?,
val artist: String?,
val album: String?,
val lengthMs: Long?,
val artworkUrl: String?
)
@Component
class JellyfinMetadataStore {
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
fun put(url: String, metadata: JellyfinMetadata) {
fun put(
url: String,
metadata: JellyfinMetadata,
) {
data[url] = metadata
}