Jellylink plugin init

This commit is contained in:
2026-02-13 21:00:46 +01:00
commit f6a28fe131
10 changed files with 709 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Gradle
.gradle/
build/
!gradle/wrapper/gradle-wrapper.jar
# IDE
.idea/
*.iml
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db

184
README.md Normal file
View File

@@ -0,0 +1,184 @@
# Jellylink Jellyfin Music Plugin for Lavalink
Play music from your **Jellyfin** media server through **Lavalink**. Jellylink is a Lavalink plugin that lets Discord bots search and stream audio directly from a Jellyfin library — no YouTube needed.
> **Keywords:** Jellyfin Lavalink plugin, Jellyfin Discord music bot, Lavalink Jellyfin source, stream Jellyfin audio Discord, self-hosted music bot
---
## Features
- **Search your Jellyfin library** from any Lavalink client using the `jfsearch:` prefix
- **Stream audio directly** — plays FLAC, MP3, OGG, and other formats from Jellyfin
- **Cover art & metadata** — track title, artist, album, duration, and artwork are passed to your client
- **Configurable audio quality** — stream original files or transcode to a specific bitrate/codec
- **Username/password authentication** — no need to manage API keys manually
- Works alongside YouTube, SoundCloud, Spotify, and all other Lavalink sources
---
## Installation
### Prerequisites
- [Lavalink v4](https://github.com/lavalink-devs/Lavalink) (tested with 4.0.8)
- A running [Jellyfin](https://jellyfin.org/) server with music in its library
- Java 17+
### Step 1 — Download or Build the Plugin
**Option A: Download the JAR**
Grab the latest `jellylink-x.x.x.jar` from the [Releases](../../releases) page.
**Option B: Build from Source**
```bash
git clone https://github.com/Myxelium/Jellylink.git
cd Jellylink
gradle 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:
```
lavalink/
├── application.yml
├── Lavalink.jar
└── plugins/
└── jellylink-0.1.0.jar ← put it here
```
If you use **Docker**, mount it into the container's plugins volume:
```yaml
volumes:
- ./application.yml:/opt/Lavalink/application.yml
- ./plugins/:/opt/Lavalink/plugins/
```
### Step 3 — Configure Lavalink
Add the following to your `application.yml` under `plugins:`:
```yaml
plugins:
jellylink:
jellyfin:
baseUrl: "http://your-jellyfin-server:8096"
username: "your_username"
password: "your_password"
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
```
#### Audio Quality Options
| Value | Bitrate | Description |
|-------------|-----------|------------------------------------------|
| `ORIGINAL` | — | Serves the raw file (FLAC, MP3, etc.) |
| `HIGH` | 320 kbps | Transcoded via Jellyfin |
| `MEDIUM` | 192 kbps | Transcoded via Jellyfin |
| `LOW` | 128 kbps | Transcoded via Jellyfin |
| `256` | 256 kbps | Any number = custom bitrate in kbps |
#### Docker Networking
If Lavalink runs in Docker and Jellyfin runs on the host:
- Use your host's LAN IP (e.g. `http://192.168.1.100:8096`)
- Or use `http://host.docker.internal:8096` (Docker Desktop)
- Or use `http://172.17.0.1:8096` (Docker bridge gateway)
### Step 4 — Restart Lavalink
Restart Lavalink and check the logs. You should see:
```
Loaded plugin: jellylink-jellyfin
```
Verify at `GET /v4/info``jellyfin` should appear under `sourceManagers`.
---
## Usage
Search your Jellyfin library using the `jfsearch:` prefix when loading tracks:
```
jfsearch:Bohemian Rhapsody
jfsearch:Daft Punk
jfsearch:Bach Cello Suite
```
### Example with Lavalink4NET (C#)
> **Lavalink4NET users:** Install the companion NuGet package [Lavalink4NET.Jellyfin](https://github.com/Myxelium/Lavalink4NET.Jellyfin) for built-in search mode support, query parsing, and source detection.
```bash
dotnet add package Lavalink4NET.Jellyfin
```
```csharp
using Lavalink4NET.Jellyfin;
using Lavalink4NET.Rest.Entities.Tracks;
// Parse user input — automatically detects jfsearch:, ytsearch:, scsearch:, etc.
var (searchMode, cleanQuery) = SearchQueryParser.Parse(
"jfsearch:Bohemian Rhapsody",
defaultMode: JellyfinSearchMode.Jellyfin // default when no prefix
);
var options = new TrackLoadOptions { SearchMode = searchMode };
var result = await audioService.Tracks.LoadTracksAsync(cleanQuery, options);
```
Or use `ParseExtended` for detailed source info:
```csharp
var result = SearchQueryParser.ParseExtended(userInput, JellyfinSearchMode.Jellyfin);
if (result.IsJellyfin)
Console.WriteLine("Searching Jellyfin library...");
Console.WriteLine($"Source: {result.SourceName}, Query: {result.Query}");
```
### Example with Lavalink.py (Python)
```python
results = await player.node.get_tracks("jfsearch:Bohemian Rhapsody")
```
### Example with Shoukaku (JavaScript)
```javascript
const result = await node.rest.resolve("jfsearch:Bohemian Rhapsody");
```
The plugin only handles identifiers starting with `jfsearch:`. All other sources (YouTube, SoundCloud, Spotify, etc.) continue to work normally.
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `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. |
---
## License
MIT

38
build.gradle.kts Normal file
View File

@@ -0,0 +1,38 @@
plugins {
kotlin("jvm") version "1.8.22"
`java-library`
}
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")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
kotlin {
jvmToolchain(17)
}

1
settings.gradle.kts Normal file
View File

@@ -0,0 +1 @@
rootProject.name = "jellylink"

View File

@@ -0,0 +1,32 @@
package dev.jellylink.jellyfin
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
import dev.arbjerg.lavalink.api.AudioPluginInfoModifier
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.springframework.stereotype.Component
@Component
class JellyfinAudioPluginInfoModifier(
private val metadataStore: JellyfinMetadataStore,
private val config: JellyfinConfig
) : AudioPluginInfoModifier {
override fun modifyAudioTrackPluginInfo(track: AudioTrack): JsonObject? {
val uri = track.info.uri ?: return null
if (!uri.startsWith(config.baseUrl.trimEnd('/'))) return null
val meta = metadataStore.get(uri) ?: return null
val map = buildMap<String, JsonPrimitive> {
meta.id.let { put("jellyfinId", JsonPrimitive(it)) }
meta.title?.let { put("jellyfinTitle", JsonPrimitive(it)) }
meta.artist?.let { put("jellyfinArtist", JsonPrimitive(it)) }
meta.album?.let { put("jellyfinAlbum", JsonPrimitive(it)) }
meta.lengthMs?.let { put("jellyfinLengthMs", JsonPrimitive(it)) }
meta.artworkUrl?.let { put("jellyfinArtworkUrl", JsonPrimitive(it)) }
}
return if (map.isEmpty()) null else JsonObject(map)
}
}

View File

@@ -0,0 +1,316 @@
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()
}
}

View File

@@ -0,0 +1,67 @@
package dev.jellylink.jellyfin
import com.sedmelluq.discord.lavaplayer.container.MediaContainerDetection
import com.sedmelluq.discord.lavaplayer.container.MediaContainerHints
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException
import com.sedmelluq.discord.lavaplayer.tools.io.PersistentHttpStream
import com.sedmelluq.discord.lavaplayer.track.AudioReference
import com.sedmelluq.discord.lavaplayer.track.AudioTrack
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
class JellyfinAudioTrack(
trackInfo: AudioTrackInfo,
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()
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
)
}
log.info("Detected container '{}' for track: {}", result.containerDescriptor.probe.name, trackInfo.title)
stream.seek(0)
processDelegate(
result.containerDescriptor.probe.createTrack(
result.containerDescriptor.parameters,
trackInfo,
stream
) as InternalAudioTrack,
executor
)
}
}
}
override fun makeShallowClone(): AudioTrack = JellyfinAudioTrack(trackInfo, sourceManager)
override fun getSourceManager(): AudioSourceManager = sourceManager
}

View File

@@ -0,0 +1,30 @@
package dev.jellylink.jellyfin
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component
@Component
@ConfigurationProperties(prefix = "plugins.jellylink.jellyfin")
class JellyfinConfig {
var baseUrl: String = ""
var username: String = ""
var password: String = ""
var searchLimit: Int = 5
/**
* Audio quality preset. Controls transcoding behavior.
* - "ORIGINAL" (default) — serves the raw file (FLAC, MP3, etc.) without transcoding
* - "HIGH" — transcodes to 320 kbps
* - "MEDIUM" — transcodes to 192 kbps
* - "LOW" — transcodes to 128 kbps
* - Any integer — custom bitrate in kbps (e.g. "256")
*/
var audioQuality: String = "ORIGINAL"
/**
* Audio codec to transcode to when audioQuality is not ORIGINAL.
* Common values: "mp3", "aac", "opus", "vorbis", "flac"
* Default: "mp3"
*/
var audioCodec: String = "mp3"
}

View File

@@ -0,0 +1,24 @@
package dev.jellylink.jellyfin
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
data class JellyfinMetadata(
val id: String,
val title: String?,
val artist: String?,
val album: String?,
val lengthMs: Long?,
val artworkUrl: String?
)
@Component
class JellyfinMetadataStore {
private val data = ConcurrentHashMap<String, JellyfinMetadata>()
fun put(url: String, metadata: JellyfinMetadata) {
data[url] = metadata
}
fun get(url: String): JellyfinMetadata? = data[url]
}

View File

@@ -0,0 +1,3 @@
name=jellylink-jellyfin
path=dev.jellylink
version=0.1.0