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 {
|
plugins {
|
||||||
kotlin("jvm") version "1.8.22"
|
kotlin("jvm") version "1.8.22"
|
||||||
alias(libs.plugins.lavalink)
|
alias(libs.plugins.lavalink)
|
||||||
|
alias(libs.plugins.detekt)
|
||||||
|
alias(libs.plugins.ktlint)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "dev.jellylink"
|
group = "dev.jellylink"
|
||||||
@@ -22,3 +24,45 @@ java {
|
|||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
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]
|
[versions]
|
||||||
lavalink-api = "4.0.8"
|
lavalink-api = "4.0.8"
|
||||||
lavalink-server = "4.0.8"
|
lavalink-server = "4.0.8"
|
||||||
|
detekt = "1.23.7"
|
||||||
|
ktlint = "12.1.2"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
lavalink = { id = "dev.arbjerg.lavalink.gradle-plugin", version = "1.0.15" }
|
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 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
|
||||||
@@ -9,12 +11,14 @@ import org.springframework.stereotype.Component
|
|||||||
@Component
|
@Component
|
||||||
class JellyfinAudioPluginInfoModifier(
|
class JellyfinAudioPluginInfoModifier(
|
||||||
private val metadataStore: JellyfinMetadataStore,
|
private val metadataStore: JellyfinMetadataStore,
|
||||||
private val config: JellyfinConfig
|
private val config: JellyfinConfig,
|
||||||
) : AudioPluginInfoModifier {
|
) : AudioPluginInfoModifier {
|
||||||
|
|
||||||
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
|
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
|
||||||
val uri = track.info.uri ?: return null
|
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
|
val meta = metadataStore.get(uri) ?: return null
|
||||||
|
|
||||||
@@ -27,6 +31,10 @@ class JellyfinAudioPluginInfoModifier(
|
|||||||
meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) }
|
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.MediaContainerDetection
|
||||||
import com.sedmelluq.discord.lavaplayer.container.MediaContainerHints
|
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.DelegatedAudioTrack
|
||||||
import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack
|
import com.sedmelluq.discord.lavaplayer.track.InternalAudioTrack
|
||||||
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor
|
import com.sedmelluq.discord.lavaplayer.track.playback.LocalAudioTrackExecutor
|
||||||
import java.net.URI
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
class JellyfinAudioTrack(
|
class JellyfinAudioTrack(
|
||||||
trackInfo: AudioTrackInfo,
|
trackInfo: AudioTrackInfo,
|
||||||
private val sourceManager: JellyfinAudioSourceManager
|
private val sourceManager: JellyfinAudioSourceManager,
|
||||||
) : DelegatedAudioTrack(trackInfo) {
|
) : DelegatedAudioTrack(trackInfo) {
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val log = LoggerFactory.getLogger(JellyfinAudioTrack::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
override fun process(executor: LocalAudioTrackExecutor) {
|
override fun process(executor: LocalAudioTrackExecutor) {
|
||||||
log.info("Processing Jellyfin track: {} ({})", trackInfo.title, trackInfo.uri)
|
log.info("Processing Jellyfin track: {} ({})", trackInfo.title, trackInfo.uri)
|
||||||
|
|
||||||
sourceManager.getHttpInterface().use { httpInterface ->
|
sourceManager.getHttpInterface().use { httpInterface ->
|
||||||
PersistentHttpStream(httpInterface, URI(trackInfo.uri), trackInfo.length).use { stream ->
|
PersistentHttpStream(httpInterface, URI(trackInfo.uri), trackInfo.length).use { stream ->
|
||||||
val result = MediaContainerDetection(
|
val result =
|
||||||
sourceManager.containerRegistry,
|
MediaContainerDetection(
|
||||||
AudioReference(trackInfo.uri, trackInfo.title),
|
sourceManager.containerRegistry,
|
||||||
stream,
|
AudioReference(trackInfo.uri, trackInfo.title),
|
||||||
MediaContainerHints.from(null, null)
|
stream,
|
||||||
).detectContainer()
|
MediaContainerHints.from(null, null),
|
||||||
|
).detectContainer()
|
||||||
|
|
||||||
if (result == null || !result.isContainerDetected) {
|
if (result == null || !result.isContainerDetected) {
|
||||||
log.error("Could not detect audio container for Jellyfin track: {}", trackInfo.title)
|
log.error("Could not detect audio container for Jellyfin track: {}", trackInfo.title)
|
||||||
|
|
||||||
throw FriendlyException(
|
throw FriendlyException(
|
||||||
"Could not detect audio format from Jellyfin stream",
|
"Could not detect audio format from Jellyfin stream",
|
||||||
FriendlyException.Severity.COMMON,
|
FriendlyException.Severity.COMMON,
|
||||||
null
|
null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +50,9 @@ class JellyfinAudioTrack(
|
|||||||
result.containerDescriptor.probe.createTrack(
|
result.containerDescriptor.probe.createTrack(
|
||||||
result.containerDescriptor.parameters,
|
result.containerDescriptor.parameters,
|
||||||
trackInfo,
|
trackInfo,
|
||||||
stream
|
stream,
|
||||||
) as InternalAudioTrack,
|
) as InternalAudioTrack,
|
||||||
executor
|
executor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,4 +61,8 @@ class JellyfinAudioTrack(
|
|||||||
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
|
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
|
||||||
|
|
||||||
override fun getSourceManager(): AudioSourceManager = 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.boot.context.properties.ConfigurationProperties
|
||||||
import org.springframework.stereotype.Component
|
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 org.springframework.stereotype.Component
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
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
|
@Component
|
||||||
class JellyfinMetadataStore {
|
class JellyfinMetadataStore {
|
||||||
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
|
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
|
||||||
|
|
||||||
fun put(url: String, metadata: JellyfinMetadata) {
|
fun put(
|
||||||
|
url: String,
|
||||||
|
metadata: JellyfinMetadata,
|
||||||
|
) {
|
||||||
data[url] = metadata
|
data[url] = metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user