feat: non-spotify tracks, refactors, bug fixes

This commit is contained in:
trafficlunar 2026-03-06 20:02:45 +00:00
parent 2dea8586f6
commit eee96c84af
9 changed files with 306 additions and 157 deletions

View file

@ -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) | | ~~**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) | | **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.

View file

@ -9,7 +9,7 @@ const isLocal = process.argv.includes("--local");
const isWatch = process.argv.includes("--watch"); const isWatch = process.argv.includes("--watch");
const options: BuildOptions = { const options: BuildOptions = {
entryPoints: ["src/app.tsx"], entryPoints: ["src/main.ts"],
outfile: "./dist/jellyfin-spicetify.js", outfile: "./dist/jellyfin-spicetify.js",
bundle: true, bundle: true,
minify: isLocal, minify: isLocal,

View file

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
// 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();

53
src/jellyfin.ts Normal file
View file

@ -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);
}
}
}
}

29
src/main.ts Normal file
View file

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0S15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189c.518 1.04 7.348 1.027 7.86 0c.511-1.027-2.874-7.19-3.93-7.19z"/></svg>`;
new Spicetify.Topbar.Button("Jellyfin", icon, () => {
Spicetify.PopupModal.display({
title: "Jellyfin",
content: React.createElement(SettingsModal) as unknown as Element,
isLarge: false,
});
});
}
main();

105
src/player.ts Normal file
View file

@ -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);
},
});
}

79
src/search.ts Normal file
View file

@ -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<HTMLDivElement>("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<HTMLImageElement>("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<HTMLSpanElement>(".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);
});
});
}

View file

@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api/quick-connect-api";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-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"; import styles from "./styles.module.css";
type View = "url" | "password" | "quick-connect" | "settings"; 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 [url, setUrl] = useState(Spicetify.LocalStorage.get("jellyfin-url") || "");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [view, setView] = useState<View>(jellyfinUser ? "settings" : "url"); const [view, setView] = useState<View>(jellyfin.user ? "settings" : "url");
const [quickConnectCode, setQuickConnectCode] = useState(""); const [quickConnectCode, setQuickConnectCode] = useState("");
const createApi = async () => { const createApi = async () => {
const servers = await jellyfin.discovery.getRecommendedServerCandidates(url); const servers = await jellyfin.sdk.discovery.getRecommendedServerCandidates(url);
const best = jellyfin.discovery.findBestServer(servers); const best = jellyfin.sdk.discovery.findBestServer(servers);
if (!best) { if (!best) {
Spicetify.showNotification("Failed to connect to server!", true); Spicetify.showNotification("Failed to connect to server!", true);
return; return;
} }
const api = jellyfin.createApi(best.address); const api = jellyfin.sdk.createApi(best.address);
Spicetify.LocalStorage.set("jellyfin-url", url); Spicetify.LocalStorage.set("jellyfin-url", url);
setJellyfinApi(api); jellyfin.setApi(api);
setView("password"); setView("password");
}; };
const login = async () => { const login = async () => {
if (!jellyfinApi) return; if (!jellyfin.api) return;
const userApi = getUserApi(jellyfinApi); const userApi = getUserApi(jellyfin.api);
const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } }); const auth = await userApi.authenticateUserByName({ authenticateUserByName: { Username: username, Pw: password } });
@ -41,11 +41,11 @@ export default function SettingsModal() {
return; return;
} }
jellyfinApi.accessToken = auth.data.AccessToken; jellyfin.api.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken); Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
const user = await getUserApi(jellyfinApi).getCurrentUser(); const user = await getUserApi(jellyfin.api).getCurrentUser();
if (user.data.Id) setJellyfinUser(user.data.Id); if (user.data.Id) jellyfin.setUser(user.data.Id);
setView("settings"); setView("settings");
}; };
@ -57,9 +57,9 @@ export default function SettingsModal() {
useEffect(() => { useEffect(() => {
if (view !== "quick-connect") return; 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; let interval: NodeJS.Timeout;
(async () => { (async () => {
@ -81,7 +81,7 @@ export default function SettingsModal() {
clearInterval(interval); clearInterval(interval);
const auth = await getUserApi(jellyfinApi!).authenticateWithQuickConnect({ const auth = await getUserApi(jellyfin.api!).authenticateWithQuickConnect({
quickConnectDto: { Secret: secret }, quickConnectDto: { Secret: secret },
}); });
@ -90,11 +90,11 @@ export default function SettingsModal() {
return; return;
} }
jellyfinApi!.accessToken = auth.data.AccessToken; jellyfin.api!.accessToken = auth.data.AccessToken;
Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken); Spicetify.LocalStorage.set("jellyfin-token", auth.data.AccessToken);
const user = await getUserApi(jellyfinApi!).getCurrentUser(); const user = await getUserApi(jellyfin.api!).getCurrentUser();
if (user.data.Id) setJellyfinUser(user.data.Id); if (user.data.Id) jellyfin.setUser(user.data.Id);
setView("settings"); setView("settings");
} catch { } catch {

View file

@ -777,6 +777,15 @@ declare namespace Spicetify {
*/ */
const Platform: { const Platform: {
PlaybackAPI: any; 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, * Queue object contains list of queuing tracks,