mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-04-09 09:59:39 +00:00
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-declaration-site = disabled
|
||||
ktlint_standard_multiline-expression-wrapping = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
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:
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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