mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-04-15 12:40:37 +00:00
Merge pull request #3 from Myxelium/minor-refactoring
Minor refactoring and cleaning
This commit is contained in:
@@ -17,3 +17,4 @@ ktlint_standard_no-consecutive-blank-lines = enabled
|
|||||||
ktlint_standard_trailing-comma-on-call-site = disabled
|
ktlint_standard_trailing-comma-on-call-site = disabled
|
||||||
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
||||||
ktlint_standard_multiline-expression-wrapping = disabled
|
ktlint_standard_multiline-expression-wrapping = disabled
|
||||||
|
ktlint_standard_function-expression-body = disabled
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "dev.jellylink"
|
group = "dev.jellylink"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
|
|
||||||
lavalinkPlugin {
|
lavalinkPlugin {
|
||||||
name = "jellylink-jellyfin"
|
name = "jellylink-jellyfin"
|
||||||
@@ -25,9 +25,6 @@ kotlin {
|
|||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Detekt — static analysis
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
detekt {
|
detekt {
|
||||||
config.setFrom(files("$rootDir/detekt.yml"))
|
config.setFrom(files("$rootDir/detekt.yml"))
|
||||||
buildUponDefaultConfig = true
|
buildUponDefaultConfig = true
|
||||||
@@ -47,9 +44,6 @@ tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
|
|||||||
exclude("**/generated/**")
|
exclude("**/generated/**")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ktlint — formatting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
ktlint {
|
ktlint {
|
||||||
version.set("1.5.0")
|
version.set("1.5.0")
|
||||||
android.set(false)
|
android.set(false)
|
||||||
@@ -60,9 +54,6 @@ ktlint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Wire lint checks into the build lifecycle
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
tasks.named("check") {
|
tasks.named("check") {
|
||||||
dependsOn("detekt", "ktlintCheck")
|
dependsOn("detekt", "ktlintCheck")
|
||||||
}
|
}
|
||||||
|
|||||||
36
detekt.yml
36
detekt.yml
@@ -1,12 +1,3 @@
|
|||||||
# =============================================================================
|
|
||||||
# Detekt configuration — strict backend production standard
|
|
||||||
# =============================================================================
|
|
||||||
# Built on top of the default config (buildUponDefaultConfig = true in Gradle).
|
|
||||||
# Only overrides and activations listed here; everything else uses defaults.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Complexity rules
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
complexity:
|
complexity:
|
||||||
# Cyclomatic complexity per function — matches ESLint complexity: 20
|
# Cyclomatic complexity per function — matches ESLint complexity: 20
|
||||||
CyclomaticComplexMethod:
|
CyclomaticComplexMethod:
|
||||||
@@ -47,9 +38,6 @@ complexity:
|
|||||||
active: true
|
active: true
|
||||||
threshold: 80
|
threshold: 80
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Empty blocks — no empty functions or classes
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
empty-blocks:
|
empty-blocks:
|
||||||
EmptyFunctionBlock:
|
EmptyFunctionBlock:
|
||||||
active: true
|
active: true
|
||||||
@@ -71,9 +59,6 @@ empty-blocks:
|
|||||||
EmptyWhenBlock:
|
EmptyWhenBlock:
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Naming conventions — forbid short / misleading identifiers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
naming:
|
naming:
|
||||||
ForbiddenClassName:
|
ForbiddenClassName:
|
||||||
active: false
|
active: false
|
||||||
@@ -91,10 +76,10 @@ naming:
|
|||||||
TopLevelPropertyNaming:
|
TopLevelPropertyNaming:
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Style rules — val preference, visibility, imports, member ordering
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
style:
|
style:
|
||||||
|
ExpressionBodySyntax:
|
||||||
|
active: false
|
||||||
|
|
||||||
# Allow guard-clause heavy functions (early returns are idiomatic Kotlin)
|
# Allow guard-clause heavy functions (early returns are idiomatic Kotlin)
|
||||||
ReturnCount:
|
ReturnCount:
|
||||||
active: true
|
active: true
|
||||||
@@ -153,8 +138,6 @@ style:
|
|||||||
excludePackageStatements: true
|
excludePackageStatements: true
|
||||||
excludeImportStatements: true
|
excludeImportStatements: true
|
||||||
|
|
||||||
|
|
||||||
# Prefer expression body for simple single-expression functions
|
|
||||||
OptionalAbstractKeyword:
|
OptionalAbstractKeyword:
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
@@ -168,9 +151,6 @@ style:
|
|||||||
singleLine: always
|
singleLine: always
|
||||||
multiLine: always
|
multiLine: always
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Potential bugs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
potential-bugs:
|
potential-bugs:
|
||||||
EqualsAlwaysReturnsTrueOrFalse:
|
EqualsAlwaysReturnsTrueOrFalse:
|
||||||
active: true
|
active: true
|
||||||
@@ -181,9 +161,6 @@ potential-bugs:
|
|||||||
IteratorNotThrowingNoSuchElementException:
|
IteratorNotThrowingNoSuchElementException:
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Performance
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
performance:
|
performance:
|
||||||
SpreadOperator:
|
SpreadOperator:
|
||||||
active: false
|
active: false
|
||||||
@@ -191,9 +168,6 @@ performance:
|
|||||||
ForEachOnRange:
|
ForEachOnRange:
|
||||||
active: true
|
active: true
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Exceptions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
exceptions:
|
exceptions:
|
||||||
TooGenericExceptionCaught:
|
TooGenericExceptionCaught:
|
||||||
active: true
|
active: true
|
||||||
@@ -220,7 +194,3 @@ exceptions:
|
|||||||
- "MalformedURLException"
|
- "MalformedURLException"
|
||||||
- "NumberFormatException"
|
- "NumberFormatException"
|
||||||
- "ParseException"
|
- "ParseException"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Global exclusions — handled in build.gradle.kts (source.setFrom, exclude)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|||||||
@@ -2,33 +2,26 @@ package dev.jellylink.jellyfin.audio
|
|||||||
|
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||||
import dev.arbjerg.lavalink.api.AudioPluginInfoModifier
|
import dev.arbjerg.lavalink.api.AudioPluginInfoModifier
|
||||||
import dev.jellylink.jellyfin.config.JellyfinConfig
|
|
||||||
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class JellyfinAudioPluginInfoModifier(
|
class JellyfinAudioPluginInfoModifier : AudioPluginInfoModifier {
|
||||||
private val metadataStore: JellyfinMetadataStore,
|
|
||||||
private val config: JellyfinConfig,
|
|
||||||
) : AudioPluginInfoModifier {
|
|
||||||
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
|
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
|
||||||
val uri = track.info.uri ?: return null
|
if (track !is JellyfinAudioTrack) {
|
||||||
|
|
||||||
if (!uri.startsWith(config.baseUrl.trimEnd('/'))) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val meta = metadataStore.get(uri) ?: return null
|
val metadata = track.info
|
||||||
|
|
||||||
val map = buildMap<String, JsonPrimitive> {
|
val map = buildMap {
|
||||||
meta.id.let { put("jellyfinId", JsonPrimitive(it)) }
|
metadata.identifier.let { put("jellyfinId", JsonPrimitive(it)) }
|
||||||
meta.title?.let { put("jellyfinTitle", JsonPrimitive(it)) }
|
metadata.title?.let { put("name", JsonPrimitive(it)) }
|
||||||
meta.artist?.let { put("jellyfinArtist", JsonPrimitive(it)) }
|
metadata.author?.let { put("artist", JsonPrimitive(it)) }
|
||||||
meta.album?.let { put("jellyfinAlbum", JsonPrimitive(it)) }
|
track.album?.let { put("albumName", JsonPrimitive(it)) }
|
||||||
meta.lengthMs?.let { put("jellyfinLengthMs", JsonPrimitive(it)) }
|
metadata.length.let { put("length", JsonPrimitive(it)) }
|
||||||
meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) }
|
metadata.artworkUrl?.let { put("artistArtworkUrl", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (map.isEmpty()) {
|
return if (map.isEmpty()) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dev.jellylink.jellyfin.audio
|
|||||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
|
import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry
|
||||||
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
|
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager
|
||||||
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
|
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
|
||||||
|
import com.sedmelluq.discord.lavaplayer.tools.DataFormatTools
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
|
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools
|
||||||
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
|
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioItem
|
import com.sedmelluq.discord.lavaplayer.track.AudioItem
|
||||||
@@ -10,24 +11,20 @@ import com.sedmelluq.discord.lavaplayer.track.AudioReference
|
|||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
|
||||||
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
||||||
import dev.jellylink.jellyfin.client.JellyfinApiClient
|
import dev.jellylink.jellyfin.client.JellyfinApiClient
|
||||||
import dev.jellylink.jellyfin.model.JellyfinMetadataStore
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.DataInput
|
import java.io.DataInput
|
||||||
|
import java.io.DataInputStream
|
||||||
import java.io.DataOutput
|
import java.io.DataOutput
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers
|
* Lavaplayer [AudioSourceManager] that resolves `jfsearch:<query>` identifiers
|
||||||
* against a Jellyfin server.
|
* against a Jellyfin server.
|
||||||
*
|
|
||||||
* This class is intentionally thin — it owns the Lavaplayer contract and delegates
|
|
||||||
* HTTP / parsing work to [JellyfinApiClient] and [dev.jellylink.jellyfin.client.JellyfinResponseParser].
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class JellyfinAudioSourceManager(
|
class JellyfinAudioSourceManager(
|
||||||
private val apiClient: JellyfinApiClient,
|
private val apiClient: JellyfinApiClient,
|
||||||
private val metadataStore: JellyfinMetadataStore,
|
|
||||||
) : AudioSourceManager {
|
) : AudioSourceManager {
|
||||||
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
|
private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
|
||||||
|
|
||||||
@@ -51,6 +48,7 @@ class JellyfinAudioSourceManager(
|
|||||||
|
|
||||||
if (!apiClient.ensureAuthenticated()) {
|
if (!apiClient.ensureAuthenticated()) {
|
||||||
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
|
log.error("Jellyfin authentication failed. Check baseUrl, username, and password in jellylink config.")
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +62,15 @@ class JellyfinAudioSourceManager(
|
|||||||
|
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
log.warn("No Jellyfin results found for query: {}", query)
|
log.warn("No Jellyfin results found for query: {}", query)
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
|
log.info("Jellyfin found: {} - {} [{}]", item.artist ?: "Unknown", item.title ?: "Unknown", item.id)
|
||||||
|
|
||||||
val playbackUrl = apiClient.buildPlaybackUrl(item.id)
|
val playbackUrl = apiClient.buildPlaybackUrl(item.id)
|
||||||
log.info("Jellyfin playback URL: {}", playbackUrl)
|
|
||||||
|
|
||||||
metadataStore.put(playbackUrl, item)
|
log.info("Jellyfin playback URL: {}", playbackUrl)
|
||||||
|
|
||||||
val trackInfo =
|
val trackInfo =
|
||||||
AudioTrackInfo(
|
AudioTrackInfo(
|
||||||
@@ -85,7 +84,7 @@ class JellyfinAudioSourceManager(
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JellyfinAudioTrack(trackInfo, this)
|
return JellyfinAudioTrack(trackInfo, item.album, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isTrackEncodable(track: AudioTrack): Boolean = true
|
override fun isTrackEncodable(track: AudioTrack): Boolean = true
|
||||||
@@ -95,17 +94,30 @@ class JellyfinAudioSourceManager(
|
|||||||
track: AudioTrack,
|
track: AudioTrack,
|
||||||
output: DataOutput,
|
output: DataOutput,
|
||||||
) {
|
) {
|
||||||
// No additional data to encode beyond AudioTrackInfo.
|
val jfTrack = track as JellyfinAudioTrack
|
||||||
|
DataFormatTools.writeNullableText(output, jfTrack.album)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun decodeTrack(
|
override fun decodeTrack(
|
||||||
trackInfo: AudioTrackInfo,
|
trackInfo: AudioTrackInfo,
|
||||||
input: DataInput,
|
input: DataInput,
|
||||||
): AudioTrack = JellyfinAudioTrack(trackInfo, this)
|
): AudioTrack {
|
||||||
|
var album: String? = null
|
||||||
|
|
||||||
|
if ((input as DataInputStream).available() > 0) {
|
||||||
|
album = DataFormatTools.readNullableText(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JellyfinAudioTrack(trackInfo, album, this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun shutdown() {
|
override fun shutdown() {
|
||||||
httpInterfaceManager.close()
|
this.httpInterfaceManager.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "Jellylink - Source manager for Jellyfin by Myxelium"
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.net.URI
|
|||||||
|
|
||||||
class JellyfinAudioTrack(
|
class JellyfinAudioTrack(
|
||||||
trackInfo: AudioTrackInfo,
|
trackInfo: AudioTrackInfo,
|
||||||
|
val album: String?,
|
||||||
private val sourceManager: JellyfinAudioSourceManager,
|
private val sourceManager: JellyfinAudioSourceManager,
|
||||||
) : DelegatedAudioTrack(trackInfo) {
|
) : DelegatedAudioTrack(trackInfo) {
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
@@ -58,7 +59,7 @@ class JellyfinAudioTrack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
|
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, album, sourceManager)
|
||||||
|
|
||||||
override fun getSourceManager(): AudioSourceManager = sourceManager
|
override fun getSourceManager(): AudioSourceManager = sourceManager
|
||||||
|
|
||||||
|
|||||||
@@ -9,119 +9,21 @@ import java.net.http.HttpClient
|
|||||||
import java.net.http.HttpRequest
|
import java.net.http.HttpRequest
|
||||||
import java.net.http.HttpResponse
|
import java.net.http.HttpResponse
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.time.Instant
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles all HTTP communication with the Jellyfin server.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Authentication (login, token refresh, invalidation)
|
|
||||||
* - Sending search requests (with automatic 401 retry)
|
|
||||||
* - Building audio playback URLs
|
|
||||||
*/
|
|
||||||
@Component
|
@Component
|
||||||
class JellyfinApiClient(
|
class JellyfinApiClient(
|
||||||
private val config: JellyfinConfig,
|
private val config: JellyfinConfig,
|
||||||
|
private val authenticator: JellyfinAuthenticator,
|
||||||
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(),
|
private val responseParser: JellyfinResponseParser = JellyfinResponseParser(),
|
||||||
) {
|
) {
|
||||||
@Volatile
|
|
||||||
var accessToken: String? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var userId: String? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var tokenObtainedAt: Instant? = null
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Authentication
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a valid access token is available, authenticating if necessary.
|
* Delegate to [JellyfinAuthenticator.ensureAuthenticated].
|
||||||
*
|
|
||||||
* @return `true` when a valid token is ready for use
|
|
||||||
*/
|
*/
|
||||||
fun ensureAuthenticated(): Boolean {
|
fun ensureAuthenticated(): Boolean = authenticator.ensureAuthenticated()
|
||||||
if (accessToken != null && userId != null && !isTokenExpired()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessToken != null && isTokenExpired()) {
|
|
||||||
log.info("Jellyfin access token expired after {} minutes, re-authenticating", config.tokenRefreshMinutes)
|
|
||||||
invalidateToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.baseUrl.isBlank() || config.username.isBlank() || config.password.isBlank()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return authenticate()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate the current token so the next call will re-authenticate.
|
|
||||||
*/
|
|
||||||
fun invalidateToken() {
|
|
||||||
log.info("Invalidating Jellyfin access token")
|
|
||||||
accessToken = null
|
|
||||||
userId = null
|
|
||||||
tokenObtainedAt = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isTokenExpired(): Boolean {
|
|
||||||
val refreshMinutes = config.tokenRefreshMinutes
|
|
||||||
|
|
||||||
if (refreshMinutes <= 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val obtainedAt = tokenObtainedAt ?: return true
|
|
||||||
|
|
||||||
return Instant.now().isAfter(obtainedAt.plusSeconds(refreshMinutes * SECONDS_PER_MINUTE))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun authenticate(): Boolean {
|
|
||||||
val url = config.baseUrl.trimEnd('/') + "/Users/AuthenticateByName"
|
|
||||||
val body = """{"Username":"${escape(config.username)}","Pw":"${escape(config.password)}"}"""
|
|
||||||
val authHeader = "MediaBrowser Client=\"Jellylink\", Device=\"Lavalink\", DeviceId=\"${UUID.randomUUID()}\", Version=\"0.1.0\""
|
|
||||||
|
|
||||||
val request = HttpRequest
|
|
||||||
.newBuilder()
|
|
||||||
.uri(java.net.URI.create(url))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("X-Emby-Authorization", authHeader)
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
|
||||||
|
|
||||||
if (response.statusCode() !in HTTP_OK_RANGE) {
|
|
||||||
log.error("Jellyfin auth failed with status {}: {}", response.statusCode(), response.body().take(ERROR_BODY_PREVIEW_LENGTH))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Successfully authenticated with Jellyfin")
|
|
||||||
val result = responseParser.parseAuthResponse(response.body()) ?: return false
|
|
||||||
|
|
||||||
accessToken = result.accessToken
|
|
||||||
userId = result.userId
|
|
||||||
tokenObtainedAt = Instant.now()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Search
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search Jellyfin for the first audio item matching [query].
|
* Search Jellyfin for the first audio item matching [query].
|
||||||
*
|
*
|
||||||
* Handles 401 retry transparently.
|
|
||||||
*
|
|
||||||
* @return parsed [JellyfinMetadata], or `null` if no result / error
|
* @return parsed [JellyfinMetadata], or `null` if no result / error
|
||||||
*/
|
*/
|
||||||
fun searchFirstAudioItem(query: String): JellyfinMetadata? {
|
fun searchFirstAudioItem(query: String): JellyfinMetadata? {
|
||||||
@@ -158,14 +60,15 @@ class JellyfinApiClient(
|
|||||||
|
|
||||||
if (response.statusCode() == HTTP_UNAUTHORIZED) {
|
if (response.statusCode() == HTTP_UNAUTHORIZED) {
|
||||||
log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating")
|
log.warn("Jellyfin returned 401 — token may have been revoked, re-authenticating")
|
||||||
invalidateToken()
|
authenticator.invalidateToken()
|
||||||
|
|
||||||
if (!ensureAuthenticated()) {
|
if (!authenticator.ensureAuthenticated()) {
|
||||||
log.error("Jellyfin re-authentication failed after 401")
|
log.error("Jellyfin re-authentication failed after 401")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val retryRequest = buildGetRequest(url) ?: return null
|
val retryRequest = buildGetRequest(url) ?: return null
|
||||||
|
|
||||||
response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString())
|
response = httpClient.send(retryRequest, HttpResponse.BodyHandlers.ofString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +76,7 @@ class JellyfinApiClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildGetRequest(url: String): HttpRequest? {
|
private fun buildGetRequest(url: String): HttpRequest? {
|
||||||
val token = accessToken ?: return null
|
val token = authenticator.accessToken ?: return null
|
||||||
|
|
||||||
return HttpRequest
|
return HttpRequest
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
@@ -183,16 +86,12 @@ class JellyfinApiClient(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Playback URL
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a streaming URL for the given Jellyfin item, respecting audio quality settings.
|
* Build a streaming URL for the given Jellyfin item, respecting audio quality settings.
|
||||||
*/
|
*/
|
||||||
fun buildPlaybackUrl(itemId: String): String {
|
fun buildPlaybackUrl(itemId: String): String {
|
||||||
val base = config.baseUrl.trimEnd('/')
|
val base = config.baseUrl.trimEnd('/')
|
||||||
val token = accessToken ?: ""
|
val token = authenticator.accessToken ?: ""
|
||||||
val quality = config.audioQuality.trim().uppercase()
|
val quality = config.audioQuality.trim().uppercase()
|
||||||
|
|
||||||
if (quality == "ORIGINAL") {
|
if (quality == "ORIGINAL") {
|
||||||
@@ -219,12 +118,6 @@ class JellyfinApiClient(
|
|||||||
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
|
return "$base/Audio/$itemId/stream?audioBitRate=$bitrate&audioCodec=$codec&api_key=$token"
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private fun escape(value: String): String = value.replace("\\", "\\\\").replace("\"", "\\\"")
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java)
|
private val log = LoggerFactory.getLogger(JellyfinApiClient::class.java)
|
||||||
private val httpClient: HttpClient = HttpClient.newHttpClient()
|
private val httpClient: HttpClient = HttpClient.newHttpClient()
|
||||||
@@ -234,7 +127,6 @@ class JellyfinApiClient(
|
|||||||
private const val ERROR_BODY_PREVIEW_LENGTH = 500
|
private const val ERROR_BODY_PREVIEW_LENGTH = 500
|
||||||
private const val DEBUG_BODY_PREVIEW_LENGTH = 2000
|
private const val DEBUG_BODY_PREVIEW_LENGTH = 2000
|
||||||
|
|
||||||
private const val SECONDS_PER_MINUTE = 60L
|
|
||||||
private const val BITRATE_HIGH = 320_000
|
private const val BITRATE_HIGH = 320_000
|
||||||
private const val BITRATE_MEDIUM = 192_000
|
private const val BITRATE_MEDIUM = 192_000
|
||||||
private const val BITRATE_LOW = 128_000
|
private const val BITRATE_LOW = 128_000
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user