From eee96c84afd75e9c5e82063d41e19461d5d029c6 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Fri, 6 Mar 2026 20:02:45 +0000 Subject: [PATCH] feat: non-spotify tracks, refactors, bug fixes --- README.md | 13 ++++ build.ts | 2 +- src/app.tsx | 139 --------------------------------------- src/jellyfin.ts | 53 +++++++++++++++ src/main.ts | 29 ++++++++ src/player.ts | 105 +++++++++++++++++++++++++++++ src/search.ts | 79 ++++++++++++++++++++++ src/settings.tsx | 34 +++++----- src/types/spicetify.d.ts | 9 +++ 9 files changed, 306 insertions(+), 157 deletions(-) delete mode 100644 src/app.tsx create mode 100644 src/jellyfin.ts create mode 100644 src/main.ts create mode 100644 src/player.ts create mode 100644 src/search.ts diff --git a/README.md b/README.md index dd052f1..8afd860 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,16 @@ WIP: A Spicetify extension to integrate your Jellyfin music library into Spotify | :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- | | ~~**Stable**~~ | ~~Latest release~~ No stable releases yet | [Download](https://github.com/trafficlunar/jellyfin-spicetify/releases/latest) | | **Unstable** | Bleeding edge (latest commit) | [Download](https://nightly.link/trafficlunar/jellyfin-spicetify/workflows/build/main/jellyfin-spicetify) | + +## Features + +- Stream music from Jellyfin instead of Spotify +- Play tracks that exist on Jellyfin but aren't available on Spotify + +## Known Limitations + +The following are current limitations with the extension. They are not impossible to implement, but are rather time-consuming or require fragile solutions. + +- Non-Spotify tracks + +Tracks that don't exist on Spotify can't be included in playlists, queue, etc. They can only be accessed via search and don't show up on the player interface. diff --git a/build.ts b/build.ts index ce7278d..7c1f4de 100644 --- a/build.ts +++ b/build.ts @@ -9,7 +9,7 @@ const isLocal = process.argv.includes("--local"); const isWatch = process.argv.includes("--watch"); const options: BuildOptions = { - entryPoints: ["src/app.tsx"], + entryPoints: ["src/main.ts"], outfile: "./dist/jellyfin-spicetify.js", bundle: true, minify: isLocal, diff --git a/src/app.tsx b/src/app.tsx deleted file mode 100644 index 934a49c..0000000 --- a/src/app.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from "react"; -import { Api, Jellyfin } from "@jellyfin/sdk"; -import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api"; -import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; -import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; -import SettingsModal from "./settings"; - -export const jellyfin = new Jellyfin({ - clientInfo: { - name: "Spicetify", - version: "1.0.0", - }, - deviceInfo: { - name: "Spotify", - id: "spotify", // TODO: should be unique? - }, -}); - -export let jellyfinApi: Api | undefined; -export const setJellyfinApi = (api: Api) => { - jellyfinApi = api; -}; -export let jellyfinUser: string | undefined; -export const setJellyfinUser = (id: string) => { - jellyfinUser = id; -}; - -let hijackActive = false; - -async function main() { - while (!Spicetify.showNotification) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - // Automatically login to Jellyfin if settings are present - const url = Spicetify.LocalStorage.get("jellyfin-url"); - const token = Spicetify.LocalStorage.get("jellyfin-token"); - - if (url && token) { - const servers = await jellyfin.discovery.getRecommendedServerCandidates(url); - const best = jellyfin.discovery.findBestServer(servers); - if (!best) { - Spicetify.showNotification("Failed to connect to Jellyfin server!", true); - return; - } - jellyfinApi = jellyfin.createApi(best.address); - jellyfinApi.accessToken = token; - - const user = await getUserApi(jellyfinApi).getCurrentUser(); - if (user.data.Id) setJellyfinUser(user.data.Id); - } - - const audio = new Audio(); - const icon = ``; - - // Topbar button for settings - new Spicetify.Topbar.Button("Jellyfin", icon, () => { - Spicetify.PopupModal.display({ - title: "Jellyfin", - content: React.createElement(SettingsModal) as unknown as Element, - isLarge: false, - }); - }); - - // Search Jellyfin for song and play that instead if found - Spicetify.Player.addEventListener("songchange", async (event) => { - if (!jellyfinApi) return; - if (!event) return; - - const results = await getSearchApi(jellyfinApi).getSearchHints({ - searchTerm: event.data.item.name, - includeItemTypes: [BaseItemKind.Audio], - limit: 1, - }); - - const item = results.data.SearchHints?.[0]; - if (!item?.Id) { - const oldVolume = Spicetify.Player.getVolume(); - hijackActive = false; - Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); - return; - } - - Spicetify.showNotification("Playing on Jellyfin"); - - const oldVolume = Spicetify.Player.getVolume(); - Spicetify.Platform.PlaybackAPI.setVolume(0); // Set Spotify audio volume to 0 - - hijackActive = true; - audio.src = `${jellyfinApi.basePath}/Audio/${item.Id}/universal?api_key=${jellyfinApi.accessToken}&UserId=${jellyfinUser}&Container=opus,webm|opus,mp3,aac,m4a|aac,m4a|alac,m4b|aac,flac,webma,webm|webma,wav,ogg&TranscodingContainer=ts&TranscodingProtocol=hls&AudioCodec=aac&MaxStreamingBitrate=140000000&EnableRedirection=true`; - await audio.play(); - - Spicetify.Platform.PlaybackAPI.setVolume(oldVolume); // Set Jellyfin audio volume to the actual volume - }); - - // Play/pause Jellyfin audio - Spicetify.Player.addEventListener("onplaypause", async (event) => { - if (!hijackActive) return; - - if (event?.data.isPaused) { - audio.pause(); - } else { - await audio.play(); - } - }); - - // Seeking support - let oldTime = 0; - Spicetify.Player.addEventListener("onprogress", async (event) => { - if (!hijackActive) return; - if (!event) return; - - // onprogress polls every 100ms, small time difference means normal playback - const timeDiff = Math.abs(event.data - oldTime); - if (Math.abs(timeDiff - 100) < 100) { - // Allow 100ms tolerance - oldTime = event.data; - return; - } - - audio.currentTime = event.data / 1000; - oldTime = event.data; - }); - - // Change volume of Jellyfin audio instead of Spotify audio - const playback = Spicetify.Platform.PlaybackAPI; - playback.setVolume = new Proxy(playback.setVolume, { - apply(target, thisArg, args) { - if (hijackActive) { - audio.volume = args[0]; - return; - } else { - return Reflect.apply(target, thisArg, args); - } - }, - }); -} - -main(); diff --git a/src/jellyfin.ts b/src/jellyfin.ts new file mode 100644 index 0000000..662673f --- /dev/null +++ b/src/jellyfin.ts @@ -0,0 +1,53 @@ +import { Api, Jellyfin } from "@jellyfin/sdk"; +import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api"; + +export const sdk = new Jellyfin({ + clientInfo: { + name: "Spicetify", + version: "1.0.0", + }, + deviceInfo: { + name: "Spotify", + id: "spotify", // TODO: should be unique? + }, +}); + +export let api: Api | undefined; +export let user: string | undefined; + +export function setApi(value: Api) { + api = value; +} +export function setUser(value: string) { + user = value; +} + +// Automatically login to Jellyfin if settings are present +export async function tryAutoLogin() { + const url = Spicetify.LocalStorage.get("jellyfin-url"); + const token = Spicetify.LocalStorage.get("jellyfin-token"); + + if (url && token) { + try { + const servers = await sdk.discovery.getRecommendedServerCandidates(url); + const best = sdk.discovery.findBestServer(servers); + if (!best) { + Spicetify.showNotification("Failed to connect to Jellyfin server!", true); + return; + } + api = sdk.createApi(best.address, token); + + const response = await getUserApi(api).getCurrentUser(); + if (response.data.Id) user = response.data.Id; + } catch (error: any) { + if (error?.response.status === 401) { + Spicetify.LocalStorage.remove("jellyfin-token"); + api = undefined; + Spicetify.showNotification("Jellyfin session expired. Please log in again.", true); + } else { + Spicetify.showNotification("Failed to connect to Jellyfin.", true); + console.error("Jellyfin init error:", error); + } + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b834d9d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,29 @@ +import React from "react"; +import SettingsModal from "./settings"; + +import * as jellyfin from "./jellyfin"; +import * as player from "./player"; +import * as search from "./search"; + +async function main() { + while (!Spicetify.showNotification) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + jellyfin.tryAutoLogin(); + player.registerEvents(); + search.init(); + + // Topbar button for settings + const icon = ``; + + new Spicetify.Topbar.Button("Jellyfin", icon, () => { + Spicetify.PopupModal.display({ + title: "Jellyfin", + content: React.createElement(SettingsModal) as unknown as Element, + isLarge: false, + }); + }); +} + +main(); diff --git a/src/player.ts b/src/player.ts new file mode 100644 index 0000000..a8dd1a6 --- /dev/null +++ b/src/player.ts @@ -0,0 +1,105 @@ +import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; +import * as jellyfin from "./jellyfin"; + +export const audio = new Audio(); +export let hijackActive = false; +export let currentVolume = 0.5; + +export function setHijackActive(value: boolean) { + hijackActive = value; +} +export function setCurrentVolume(value: number) { + currentVolume = value; +} + +export async function playTrack(id: string) { + const oldVolume = Spicetify.Player.getVolume(); + Spicetify.Player.setVolume(0); // Set Spotify audio volume to 0 + + setHijackActive(true); + audio.src = `${jellyfin.api?.basePath}/Audio/${id}/universal?api_key=${jellyfin.api?.accessToken}&UserId=${jellyfin.user}&Container=flac,aac,mp3&AudioCodec=flac,aac&MaxStreamingBitrate=140000000&EnableRedirection=true`; + await audio.play(); + + Spicetify.Player.setVolume(oldVolume); // Volume is now hijacked, will now set Jellyfin audio volume and also update the volume slider +} + +export function registerEvents() { + // Search Jellyfin for song and play that instead if found + Spicetify.Player.addEventListener("songchange", async (event) => { + if (!jellyfin.api) return; + if (!event) return; + + const results = await getSearchApi(jellyfin.api).getSearchHints({ + searchTerm: event.data.item.name, + includeItemTypes: [BaseItemKind.Audio], + limit: 1, + }); + + const item = results.data.SearchHints?.[0]; + if (!item?.Id) { + setHijackActive(false); + audio.pause(); + Spicetify.Player.setVolume(currentVolume); + return; + } + + Spicetify.showNotification("Playing on Jellyfin"); + playTrack(item.Id); + }); + + // Play/pause Jellyfin audio + Spicetify.Player.addEventListener("onplaypause", async (event) => { + if (!hijackActive) return; + + if (event?.data.isPaused) { + audio.pause(); + } else { + await audio.play(); + } + + Spicetify.Player.setVolume(currentVolume); + }); + + // Seeking support + let oldTime = 0; + Spicetify.Player.addEventListener("onprogress", async (event) => { + if (!hijackActive) return; + if (!event) return; + + // onprogress polls every 100ms, small time difference means normal playback + const timeDiff = Math.abs(event.data - oldTime); + if (Math.abs(timeDiff - 100) < 100) { + // Allow 100ms tolerance + oldTime = event.data; + return; + } + + audio.currentTime = event.data / 1000; + oldTime = event.data; + }); + + // Change volume of Jellyfin audio instead of Spotify audio + const playback = Spicetify.Platform.PlaybackAPI; + playback.getVolume = new Proxy(playback.getVolume, { + apply(target, thisArg, args) { + if (hijackActive) { + return currentVolume; + } + return Reflect.apply(target, thisArg, args); + }, + }); + playback.setVolume = new Proxy(playback.setVolume, { + apply(target, thisArg, args) { + if (hijackActive) { + setCurrentVolume(args[0]); + audio.volume = Math.pow(currentVolume, 3); + + const volumeSlider: HTMLDivElement | null = document.querySelector(".volume-bar__slider-container > div > div"); + if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`); + return; + } + return Reflect.apply(target, thisArg, args); + }, + }); +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..96df8f2 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,79 @@ +import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; +import * as jellyfin from "./jellyfin"; +import * as player from "./player"; + +// Add Jellyfin tracks to search (usually for songs not available on Spotify) +export function init() { + Spicetify.Platform.History.listen(async (location) => { + if (!jellyfin.api) return; + if (!location.pathname.startsWith("/search/")) return; + + const segments = location.pathname.split("/"); + const query = segments[2]; + + const results = await getSearchApi(jellyfin.api).getSearchHints({ + searchTerm: query, + includeItemTypes: [BaseItemKind.Audio], + limit: 4, + }); + + const searchHints = results.data.SearchHints; + if (!searchHints || searchHints.length === 0) return; + + const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1]; + if (!parent) return; + + // Use actual track as a template + const template = parent.querySelector("div"); + if (!template) return; + + searchHints.forEach((trackInfo) => { + // TODO: Skip if Spotify already has this track in its results (it will be hijacked instead) + + const track = template.cloneNode(true) as HTMLDivElement; + const sectionStart = track.querySelector(".main-trackList-rowSectionStart"); + const sectionEnd = track.querySelector(".main-trackList-rowSectionEnd"); + const rowContent = track.querySelector(".main-trackList-rowMainContent"); + const albumCover = sectionStart?.querySelector("img"); + const songTitle = rowContent?.querySelector("div"); + rowContent?.querySelector(".encore-text-body-medium.encore-internal-color-text-subdued")?.remove(); // Remove explicit icon + const songArtist = rowContent?.querySelector(".encore-text-body-small > span"); + const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued"); + const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement; + + if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return; + + // Remove all children of sectionEnd except duration and context menu button + Array.from(sectionEnd.children).forEach((child) => { + if (child !== duration || child !== contextMenuButton) child.remove(); + }); + + // Instead of removing, hide it to keep gap + contextMenuButton.style.opacity = "0"; + + // TODO: fallback image + albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution + albumCover.srcset = ""; + songTitle.textContent = trackInfo.Name ?? "Unknown title"; + songArtist.innerHTML = ""; // Remove hyperlink to artist page + songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist"; + + // Set duration text + if (trackInfo.RunTimeTicks) { + const durationMs = trackInfo.RunTimeTicks / 10000; + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`; + } + + track.addEventListener("dblclick", () => { + Spicetify.Player.pause(); + // TODO: hijack player html + player.playTrack(trackInfo.Id!); + }); + + parent.insertBefore(track, parent.firstChild); + }); + }); +} diff --git a/src/settings.tsx b/src/settings.tsx index d5bec08..b1b8286 100644 --- a/src/settings.tsx +++ b/src/settings.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api"; import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api"; -import { jellyfin, jellyfinApi, jellyfinUser, setJellyfinApi, setJellyfinUser } from "./app"; +import * as jellyfin from "./jellyfin"; import styles from "./styles.module.css"; type View = "url" | "password" | "quick-connect" | "settings"; @@ -12,27 +12,27 @@ export default function SettingsModal() { const [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || ""); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [view, setView] = useState(jellyfinUser ? "settings" : "url"); + const [view, setView] = useState(jellyfin.user ? "settings" : "url"); const [quickConnectCode, setQuickConnectCode] = useState(""); const createApi = async () => { - const servers = await jellyfin.discovery.getRecommendedServerCandidates(url); - const best = jellyfin.discovery.findBestServer(servers); + const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url); + const best = jellyfin.sdk.discovery.findBestServer(servers); if (!best) { Spicetify.showNotification("Failed to connect to server!", true); return; } - const api = jellyfin.createApi(best.address); + const api = jellyfin.sdk.createApi(best.address); Spicetify.LocalStorage.set("jellyfin-url", url); - setJellyfinApi(api); + jellyfin.setApi(api); setView("password"); }; const login = async () => { - if (!jellyfinApi) return; - const userApi = getUserApi(jellyfinApi); + if (!jellyfin.api) return; + const userApi = getUserApi(jellyfin.api); const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } }); @@ -41,11 +41,11 @@ export default function SettingsModal() { return; } - jellyfinApi.accessToken = auth.data.AccessToken; + jellyfin.api.accessToken = auth.data.AccessToken; Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken); - const user = await getUserApi(jellyfinApi).getCurrentUser(); - if (user.data.Id) setJellyfinUser(user.data.Id); + const user = await getUserApi(jellyfin.api).getCurrentUser(); + if (user.data.Id) jellyfin.setUser(user.data.Id); setView("settings"); }; @@ -57,9 +57,9 @@ export default function SettingsModal() { useEffect(() => { if (view !== "quick-connect") return; - if (!jellyfinApi) return; + if (!jellyfin.api) return; - const quickConnectApi = getQuickConnectApi(jellyfinApi); + const quickConnectApi = getQuickConnectApi(jellyfin.api); let interval: NodeJS.Timeout; (async () => { @@ -81,7 +81,7 @@ export default function SettingsModal() { clearInterval(interval); - const auth = await getUserApi(jellyfinApi!).authenticateWithQuickConnect({ + const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({ quickConnectDto: { Secret: secret }, }); @@ -90,11 +90,11 @@ export default function SettingsModal() { return; } - jellyfinApi!.accessToken = auth.data.AccessToken; + jellyfin.api!.accessToken = auth.data.AccessToken; Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken); - const user = await getUserApi(jellyfinApi!).getCurrentUser(); - if (user.data.Id) setJellyfinUser(user.data.Id); + const user = await getUserApi(jellyfin.api!).getCurrentUser(); + if (user.data.Id) jellyfin.setUser(user.data.Id); setView("settings"); } catch { diff --git a/src/types/spicetify.d.ts b/src/types/spicetify.d.ts index dfa4c9f..0415f7f 100644 --- a/src/types/spicetify.d.ts +++ b/src/types/spicetify.d.ts @@ -777,6 +777,15 @@ declare namespace Spicetify { */ const Platform: { PlaybackAPI: any; + History: { + push: (path: Location | string) => void; + replace: (path: Location | string) => void; + goBack: () => void; + goForward: () => void; + listen: (listener: (location: Location) => void) => () => void; + entries: Location[]; + location: Location; + }; }; /** * Queue object contains list of queuing tracks,