mirror of
https://github.com/Myxelium/Jellylink.git
synced 2026-04-09 09:59:39 +00:00
Jellylink plugin init
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
184
README.md
Normal 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
38
build.gradle.kts
Normal 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
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "jellylink"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt
Normal file
67
src/main/kotlin/dev/jellylink/jellyfin/JellyfinAudioTrack.kt
Normal 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
|
||||||
|
}
|
||||||
30
src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt
Normal file
30
src/main/kotlin/dev/jellylink/jellyfin/JellyfinConfig.kt
Normal 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"
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
3
src/main/resources/lavalink-plugins/jellylink.properties
Normal file
3
src/main/resources/lavalink-plugins/jellylink.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name=jellylink-jellyfin
|
||||||
|
path=dev.jellylink
|
||||||
|
version=0.1.0
|
||||||
Reference in New Issue
Block a user