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,