diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5359bcb
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1aa26a5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Build & check
+ run: ./gradlew build
+
+ - name: Upload JAR artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: jellylink-jar
+ path: build/libs/*.jar
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..97ca4f5
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,60 @@
+name: Release
+
+on:
+ push:
+ branches: [master]
+
+# Prevent concurrent releases
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history for tag checks
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Extract version from build.gradle.kts
+ id: version
+ run: |
+ VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts)
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "Detected version: $VERSION"
+
+ - name: Check if tag already exists
+ id: tag_check
+ run: |
+ if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "Tag v${{ steps.version.outputs.version }} already exists — skipping release."
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build
+ if: steps.tag_check.outputs.exists == 'false'
+ run: ./gradlew build
+
+ - name: Create tag and GitHub release
+ if: steps.tag_check.outputs.exists == 'false'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ steps.version.outputs.version }}
+ name: v${{ steps.version.outputs.version }}
+ generate_release_notes: true
+ files: build/libs/*.jar
diff --git a/README.md b/README.md
index d411159..49093f2 100644
--- a/README.md
+++ b/README.md
@@ -25,26 +25,38 @@ Play music from your **Jellyfin** media server through **Lavalink**. Jellylink i
- A running [Jellyfin](https://jellyfin.org/) server with music in its library
- Java 17+
-### Step 1 — Download or Build the Plugin
+### Step 1 — Add the Plugin to Lavalink
+
+Add the following to your Lavalink `application.yml`:
+
+```yaml
+lavalink:
+ plugins:
+ - dependency: com.github.Myxelium:Jellylink:v0.1.0
+ repository: https://jitpack.io
+```
+
+> **Tip:** Replace `v0.1.0` with the version you want. Check available versions on the [Releases](https://github.com/Myxelium/Jellylink/releases) page.
+
+Lavalink will automatically download the plugin on startup.
+
+
+Alternative: Manual Installation
**Option A: Download the JAR**
-Grab the latest `jellylink-x.x.x.jar` from the [Releases](../../releases) page.
+Grab the latest `jellylink-x.x.x.jar` from the [Releases](https://github.com/Myxelium/Jellylink/releases) page.
**Option B: Build from Source**
```bash
git clone https://github.com/Myxelium/Jellylink.git
cd Jellylink
-gradle build
+./gradlew build
```
The JAR will be at `build/libs/jellylink-0.1.0.jar`.
-> **Tip:** If you don't have Gradle installed, run `gradle wrapper --gradle-version 8.7` first, then use `./gradlew build`.
-
-### Step 2 — Install the Plugin
-
Copy the JAR into your Lavalink `plugins/` directory:
```
@@ -63,7 +75,9 @@ volumes:
- ./plugins/:/opt/Lavalink/plugins/
```
-### Step 3 — Configure Lavalink
+
+
+### Step 2 — Configure Lavalink
Add the following to your `application.yml` under `plugins:`:
@@ -77,6 +91,7 @@ plugins:
searchLimit: 5 # max results to return (default: 5)
audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom kbps
audioCodec: "mp3" # only used when audioQuality is not ORIGINAL
+ tokenRefreshMinutes: 30 # re-authenticate every N minutes (0 = only on 401)
```
#### Audio Quality Options
@@ -96,7 +111,7 @@ If Lavalink runs in Docker and Jellyfin runs on the host:
- Or use `http://host.docker.internal:8096` (Docker Desktop)
- Or use `http://172.17.0.1:8096` (Docker bridge gateway)
-### Step 4 — Restart Lavalink
+### Step 3 — Restart Lavalink
Restart Lavalink and check the logs. You should see:
@@ -174,8 +189,7 @@ The plugin only handles identifiers starting with `jfsearch:`. All other sources
| `Jellyfin authentication failed` | Check `baseUrl`, `username`, and `password`. Make sure the URL is reachable from the Lavalink host/container. |
| `No Jellyfin results found` | Verify the song exists in your Jellyfin library and that the user has access to it. |
| `Unknown file format` | Update to the latest version — this was fixed by using direct audio streaming. |
-| No cover art | Update to the latest version — artwork URLs are now always included. |
-| Unicode characters broken (e.g. `\u0026`) | Update to the latest version — JSON escape sequences are now decoded. |
+| `No cover art` | Update to the latest version — artwork URLs are now always included. Jellyfin has to be public to internet.|
---
diff --git a/build.gradle.kts b/build.gradle.kts
index 6178d25..3965f0d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,38 +1,68 @@
plugins {
kotlin("jvm") version "1.8.22"
- `java-library`
+ alias(libs.plugins.lavalink)
+ alias(libs.plugins.detekt)
+ alias(libs.plugins.ktlint)
}
group = "dev.jellylink"
version = "0.1.0"
-repositories {
- // Lavalink / Lavaplayer artifacts
- maven("https://maven.lavalink.dev/releases")
-
- mavenCentral()
-}
-
-dependencies {
- // Lavalink plugin API (adjust version to match your Lavalink server)
- compileOnly("dev.arbjerg.lavalink:plugin-api:4.0.8")
-
- // Lavaplayer (provided by Lavalink at runtime; keep as compileOnly)
- compileOnly("dev.arbjerg:lavaplayer:2.2.2")
-
- // Spring annotations (provided by Lavalink, but needed for compilation)
- compileOnly("org.springframework.boot:spring-boot-starter-web:3.1.0")
-
- // JSON types used by the plugin API
- compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
+lavalinkPlugin {
+ name = "jellylink-jellyfin"
+ path = "dev.jellylink"
+ apiVersion = libs.versions.lavalink.api
+ serverVersion = libs.versions.lavalink.server
}
java {
toolchain {
- languageVersion.set(JavaLanguageVersion.of(17))
+ languageVersion = JavaLanguageVersion.of(17)
}
}
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().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")
+}
diff --git a/detekt.yml b/detekt.yml
new file mode 100644
index 0000000..212ecf7
--- /dev/null
+++ b/detekt.yml
@@ -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)
+# ---------------------------------------------------------------------------
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..026eb86
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +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" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e644113
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..1aa94a4
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..7101f8e
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/jitpack.yml b/jitpack.yml
new file mode 100644
index 0000000..31ca701
--- /dev/null
+++ b/jitpack.yml
@@ -0,0 +1,4 @@
+jdk:
+ - openjdk17
+install:
+ - ./gradlew clean publishToMavenLocal
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt
deleted file mode 100644
index ed47452..0000000
--- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioSourceManager.kt
+++ /dev/null
@@ -1,316 +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.util.UUID
-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()
- val containerRegistry: MediaContainerRegistry = MediaContainerRegistry.DEFAULT_REGISTRY
- private val httpInterfaceManager = HttpClientTools.createDefaultThreadLocalManager()
- @Volatile
- private var accessToken: String? = null
- @Volatile
- private var userId: String? = 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)
- }
-
- private fun ensureAuthenticated(): Boolean {
- if (accessToken != null && userId != null) return true
- 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 bodyText = response.body()
- val tokenKey = "\"AccessToken\":\""
- val tokenIndex = bodyText.indexOf(tokenKey)
- if (tokenIndex == -1) return false
- val tokenStart = tokenIndex + tokenKey.length
- val tokenEnd = bodyText.indexOf('"', tokenStart)
- if (tokenEnd <= tokenStart) return false
- val token = bodyText.substring(tokenStart, tokenEnd)
-
- val userKey = "\"User\":{"
- val userIndex = bodyText.indexOf(userKey)
- if (userIndex == -1) return false
- val idKey = "\"Id\":\""
- val idIndex = bodyText.indexOf(idKey, userIndex)
- if (idIndex == -1) return false
- val idStart = idIndex + idKey.length
- val idEnd = bodyText.indexOf('"', idStart)
- if (idEnd <= idStart) return false
- val uid = bodyText.substring(idStart, idEnd)
-
- accessToken = token
- userId = uid
- 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()
-
- val response = httpClient.send(request, 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))
-
- // Find the first item in the Items array
- val itemsIdx = body.indexOf("\"Items\":[")
- if (itemsIdx == -1) return null
- val firstItemStart = body.indexOf("{", itemsIdx + 9)
- if (firstItemStart == -1) return null
-
- // Take a generous chunk for the first item
- val itemChunk = body.substring(firstItemStart, minOf(body.length, firstItemStart + 5000))
-
- val id = extractJsonString(itemChunk, "Id") ?: return null
- val title = extractJsonString(itemChunk, "Name")
- val artist = extractJsonString(itemChunk, "AlbumArtist")
- ?: extractFirstArrayElement(itemChunk, "Artists")
- val album = extractJsonString(itemChunk, "Album")
-
- val runtimeTicks = extractJsonLong(itemChunk, "RunTimeTicks")
- val lengthMs = runtimeTicks?.let { it / 10_000 }
-
- val imageTag = extractJsonString(itemChunk, "Primary")
- // Always provide an artwork URL — Jellyfin will serve the image even without the tag param
- 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 extractJsonString(json: String, key: String): String? {
- val pattern = "\"$key\":\""
- val idx = json.indexOf(pattern)
- if (idx == -1) return null
- val start = idx + pattern.length
- val end = findUnescapedQuote(json, start)
- return if (end > start) unescapeJson(json.substring(start, end)) else null
- }
-
- private fun extractFirstArrayElement(json: String, key: String): String? {
- val pattern = "\"$key\":[\""
- val idx = json.indexOf(pattern)
- if (idx == -1) return null
- val start = idx + pattern.length
- val end = findUnescapedQuote(json, start)
- return if (end > start) unescapeJson(json.substring(start, end)) else null
- }
-
- /** Find the next unescaped double-quote starting from [from]. */
- private fun findUnescapedQuote(json: String, from: Int): Int {
- var i = from
- while (i < json.length) {
- when (json[i]) {
- '\\' -> i += 2 // skip escaped character
- '"' -> return i
- else -> i++
- }
- }
- return -1
- }
-
- /** Decode JSON string escape sequences: \\uXXXX, \\n, \\t, \\\\, \\", etc. */
- private fun unescapeJson(s: String): String {
- if (!s.contains('\\')) return s
- val sb = StringBuilder(s.length)
- var i = 0
- while (i < s.length) {
- if (s[i] == '\\' && i + 1 < s.length) {
- when (s[i + 1]) {
- 'u' -> {
- if (i + 5 < s.length) {
- val hex = s.substring(i + 2, i + 6)
- val cp = hex.toIntOrNull(16)
- if (cp != null) {
- sb.append(cp.toChar())
- i += 6
- continue
- }
- }
- sb.append(s[i])
- i++
- }
- 'n' -> { sb.append('\n'); i += 2 }
- 't' -> { sb.append('\t'); i += 2 }
- 'r' -> { sb.append('\r'); i += 2 }
- '\\' -> { sb.append('\\'); i += 2 }
- '"' -> { sb.append('"'); i += 2 }
- '/' -> { sb.append('/'); i += 2 }
- else -> { sb.append(s[i]); i++ }
- }
- } else {
- sb.append(s[i])
- i++
- }
- }
- return sb.toString()
- }
-
- private fun extractJsonLong(json: String, key: String): Long? {
- val pattern = "\"$key\":"
- val idx = json.indexOf(pattern)
- if (idx == -1) return null
- val start = idx + pattern.length
- var end = start
- while (end < json.length && json[end].isDigit()) end++
- return if (end > start) json.substring(start, end).toLongOrNull() else null
- }
-
- 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()
- }
-}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt
similarity index 73%
rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt
rename to src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt
index 26045a4..9fc1113 100644
--- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioPluginInfoModifier.kt
+++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioPluginInfoModifier.kt
@@ -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)
+ }
}
}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt
new file mode 100644
index 0000000..8e4622b
--- /dev/null
+++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioSourceManager.kt
@@ -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:` 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:"
+ }
+}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt
similarity index 81%
rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt
rename to src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt
index 1690582..f585937 100644
--- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt
+++ b/src/main/kotlin/dev/jellylink/jellyfin/audio/JellyfinAudioTrack.kt
@@ -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)
+ }
}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt
new file mode 100644
index 0000000..f73fb37
--- /dev/null
+++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinApiClient.kt
@@ -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? {
+ 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
+ }
+}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt
new file mode 100644
index 0000000..ac36509
--- /dev/null
+++ b/src/main/kotlin/dev/jellylink/jellyfin/client/JellyfinResponseParser.kt
@@ -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
+ }
+}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt b/src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt
similarity index 72%
rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt
rename to src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt
index 13cbcd6..39fa063 100644
--- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt
+++ b/src/main/kotlin/dev/jellylink/jellyfin/config/JellyfinConfig.kt
@@ -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
@@ -27,4 +27,12 @@ class JellyfinConfig {
* Default: "mp3"
*/
var audioCodec: String = "mp3"
+
+ /**
+ * How often (in minutes) the Jellyfin access token should be refreshed.
+ * The plugin will re-authenticate automatically before the token expires.
+ * Set to 0 to disable automatic refresh (token is obtained once and reused until a 401 occurs).
+ * Default: 30
+ */
+ var tokenRefreshMinutes: Int = 30
}
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt
new file mode 100644
index 0000000..65d25e3
--- /dev/null
+++ b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadata.kt
@@ -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?,
+)
diff --git a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt
similarity index 53%
rename from src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt
rename to src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt
index 59f1b66..f950d4b 100644
--- a/src/main/kotlin/dev/jellylink/jellyfin/JellyfinMetadataStore.kt
+++ b/src/main/kotlin/dev/jellylink/jellyfin/model/JellyfinMetadataStore.kt
@@ -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()
- fun put(url: String, metadata: JellyfinMetadata) {
+ fun put(
+ url: String,
+ metadata: JellyfinMetadata,
+ ) {
data[url] = metadata
}
diff --git a/src/main/resources/lavalink-plugins/jellylink.properties b/src/main/resources/lavalink-plugins/jellylink.properties
deleted file mode 100644
index a1a8dea..0000000
--- a/src/main/resources/lavalink-plugins/jellylink.properties
+++ /dev/null
@@ -1,3 +0,0 @@
-name=jellylink-jellyfin
-path=dev.jellylink
-version=0.1.0