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