mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-04-09 09:59:39 +00:00
Add linter
This commit is contained in:
19
.editorconfig
Normal file
19
.editorconfig
Normal 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
|
||||
@@ -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
226
detekt.yml
Normal 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package dev.jellylink.jellyfin
|
||||
package dev.jellylink.jellyfin.audio
|
||||
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerDetection
|
||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerHints
|
||||
@@ -11,37 +11,34 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo
|
||||
import com.sedmelluq.discord.lavaplayer.track.DelegatedAudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack
|
||||
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor
|
||||
import java.net.URI
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.net.URI
|
||||
|
||||
class JellyfinAudioTrack(
|
||||
trackInfo: AudioTrackInfo,
|
||||
private val sourceManager: JellyfinAudioSourceManager
|
||||
private val sourceManager: JellyfinAudioSourceManager,
|
||||
) : DelegatedAudioTrack(trackInfo) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinAudioTrack::class.java)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun process(executor: LocalAudioTrackExecutor) {
|
||||
log.info("Processing Jellyfin track: {} ({})", trackInfo.title, trackInfo.uri)
|
||||
|
||||
sourceManager.getHttpInterface().use { httpInterface ->
|
||||
PersistentHttpStream(httpInterface, URI(trackInfo.uri), trackInfo.length).use { stream ->
|
||||
val result = MediaContainerDetection(
|
||||
sourceManager.containerRegistry,
|
||||
AudioReference(trackInfo.uri, trackInfo.title),
|
||||
stream,
|
||||
MediaContainerHints.from(null, null)
|
||||
).detectContainer()
|
||||
val result =
|
||||
MediaContainerDetection(
|
||||
sourceManager.containerRegistry,
|
||||
AudioReference(trackInfo.uri, trackInfo.title),
|
||||
stream,
|
||||
MediaContainerHints.from(null, null),
|
||||
).detectContainer()
|
||||
|
||||
if (result == null || !result.isContainerDetected) {
|
||||
log.error("Could not detect audio container for Jellyfin track: {}", trackInfo.title)
|
||||
|
||||
throw FriendlyException(
|
||||
"Could not detect audio format from Jellyfin stream",
|
||||
FriendlyException.Severity.COMMON,
|
||||
null
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,9 +50,9 @@ class JellyfinAudioTrack(
|
||||
result.containerDescriptor.probe.createTrack(
|
||||
result.containerDescriptor.parameters,
|
||||
trackInfo,
|
||||
stream
|
||||
stream,
|
||||
) as InternalAudioTrack,
|
||||
executor
|
||||
executor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,4 +61,8 @@ class JellyfinAudioTrack(
|
||||
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
|
||||
|
||||
override fun getSourceManager(): AudioSourceManager = sourceManager
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(JellyfinAudioTrack::class.java)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user