Compare commits

..

3 commits

Author SHA1 Message Date
10e2f22e61
feat: create FUNDING.yml
Some checks failed
build / build (push) Has been cancelled
build / deploy (push) Has been cancelled
2026-07-10 14:23:14 +01:00
427057d17e feat: more fleshed-out site
forgot to say for ce4ea63a but we migrated to pnpm from bun
2026-07-10 14:16:46 +01:00
ce4ea63abd feat: add website 2026-07-10 13:30:28 +01:00
7 changed files with 380 additions and 99 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
ko_fi: trafficlunar

View file

@ -1,16 +1,61 @@
name: build name: build
on: [push]
on:
push:
branches: ["main"]
# Allows running this workflow manually
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Only one Pages deployment at a time, let in-progress ones finish
concurrency:
group: "pages"
cancel-in-progress: false
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2 - uses: pnpm/action-setup@v6
- run: bun install with:
- run: bun run build-local version: latest
- uses: actions/setup-node@v4
with:
node-version: latest
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build-local
- uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
with: with:
name: jellyfin-spicetify name: jellyfin-spicetify
path: dist/jellyfin-spicetify.js path: dist/jellyfin-spicetify.js
# archive: false archive: false
- name: Assemble site
run: |
mkdir -p site/unstable
cp dist/jellyfin-spicetify.js site/unstable/jellyfin-spicetify.js
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View file

@ -1,19 +1,10 @@
# jellyfin-spicetify # jellyfin-spicetify
WIP: Spicetify extension to integrate your Jellyfin music library into Spotify > A Spicetify extension to integrate your Jellyfin music library into Spotify
## Downloads ### [Go to the website](https://jellyfin-spicetify.trafficlunar.net)
| Version | Description | Links | Stable/beta downloads are on the site + releases (stable).
| :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- |
| ~~**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
- Easily toggle between Jellyfin and Spotify audio
## Known Limitations ## Known Limitations

View file

@ -33,6 +33,7 @@ const options: BuildOptions = {
name: "on-end", name: "on-end",
setup(build) { setup(build) {
build.onEnd(() => { build.onEnd(() => {
// Bundle the CSS into the final JS file
const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8"); const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8");
const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8"); const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8");

260
site/index.html Normal file
View file

@ -0,0 +1,260 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jellyfin-spicetify</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #0b0b10;
--surface: #15151d;
--elevated: #222230;
--primary: #aa5cc3;
--primary-dark: #8e3daf;
--accent: #1db954;
--text: #e8e8ea;
--text-secondary: #cdd0d6;
--text-muted: #8a8a96;
--border: #30303d;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
font-family: "Plus Jakarta Sans", sans-serif;
letter-spacing: -0.02em;
background-image:
radial-gradient(circle at 15% 15%, rgba(170, 92, 195, 0.21) 0%, transparent 50%),
radial-gradient(circle at 85% 15%, rgba(29, 185, 84, 0.18) 0%, transparent 50%);
}
svg {
fill: var(--accent);
width: 2.5rem;
height: 2.5rem;
}
main {
background-color: var(--surface);
border: 1px solid var(--border);
max-width: 30rem;
padding: 2rem;
border-radius: 0.5rem;
margin-top: 4rem;
height: min-content;
}
header {
display: flex;
align-items: center;
gap: 1rem;
}
h1 {
margin: 0;
font-weight: 900;
display: inline-block;
}
#repo {
margin-left: auto;
color: var(--text-muted);
opacity: 0.5;
align-self: self-start;
}
#repo:hover {
color: var(--text);
}
#repo svg {
width: 1.75rem;
height: 1.75rem;
}
h3 {
margin: 0;
}
p {
color: var(--text-secondary);
}
ul {
margin-top: 0.25rem;
}
li {
color: var(--text-secondary);
line-height: 1.75;
}
li::marker {
color: var(--primary);
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
.button {
background: var(--primary);
border: 1px solid var(--primary);
color: white;
font-weight: 600;
text-decoration: none;
padding: 0.45rem 0.7rem;
border-radius: 0.25rem;
font-size: 0.925rem;
text-align: center;
transition:
transform 0.15s,
background 0.15s;
}
.button:hover {
background: var(--primary-dark);
}
.secondary {
background: transparent;
border-color: var(--border);
}
.secondary:hover {
background: var(--elevated);
}
.marketplace-note {
font-size: 0.875rem;
font-style: italic;
color: var(--text-muted);
}
.help-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.help-container h3 {
margin: 0;
margin-bottom: 0.5rem;
}
.help-text-block p {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted);
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
}
#author {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9rem;
}
#author:hover {
color: #ffa258;
}
@media (max-width: 500px) {
main {
margin: 1rem;
padding: 1.5rem;
}
}
</style>
</head>
<body>
<main>
<header>
<svg width="72" height="72" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2143_3811)">
<path
d="M24.2116 49.1581C22.6599 46.0424 32.8378 27.5879 35.9999 27.5879C39.1666 27.5895 49.3228 46.0764 47.7882 49.1581C46.2536 52.2398 25.7632 52.2738 24.2116 49.1581Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.481861 64.9951C-4.19479 55.6047 26.4765 0 36 0C45.5328 0 76.153 55.713 71.5274 64.9951C66.9018 74.2773 5.15852 74.3856 0.481861 64.9951ZM12.7358 56.847C15.8005 62.9995 56.2536 62.9314 59.2843 56.847C62.3149 50.761 42.2515 14.2605 36.0093 14.2605C29.767 14.2605 9.67118 50.6944 12.7358 56.847Z"
/>
</g>
<defs>
<clipPath id="clip0_2143_3811">
<rect width="72" height="72" fill="white" />
</clipPath>
</defs>
</svg>
<h1>jellyfin-spicetify</h1>
<a href="https://github.com/trafficlunar/jellyfin-spicetify/issues" id="repo">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<g fill="none">
<path
d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z"
/>
<path
fill="currentColor"
d="M6.315 6.176c-.25-.638-.24-1.367-.129-2.034a6.8 6.8 0 0 1 2.12 1.07c.28.214.647.283.989.18A9.3 9.3 0 0 1 12 5c.961 0 1.874.14 2.703.391c.342.104.709.034.988-.18a6.8 6.8 0 0 1 2.119-1.07c.111.667.12 1.396-.128 2.033c-.15.384-.075.826.208 1.14C18.614 8.117 19 9.04 19 10c0 2.114-1.97 4.187-5.134 4.818c-.792.158-1.101 1.155-.495 1.726c.389.366.629.882.629 1.456v3a1 1 0 0 0 2 0v-3c0-.57-.12-1.112-.334-1.603C18.683 15.35 21 12.993 21 10c0-1.347-.484-2.585-1.287-3.622c.21-.82.191-1.646.111-2.28c-.071-.568-.17-1.312-.57-1.756c-.595-.659-1.58-.271-2.28-.032a9 9 0 0 0-2.125 1.045A11.4 11.4 0 0 0 12 3c-.994 0-1.953.125-2.851.356a9 9 0 0 0-2.125-1.045c-.7-.24-1.686-.628-2.281.031c-.408.452-.493 1.137-.566 1.719l-.005.038c-.08.635-.098 1.462.112 2.283C3.484 7.418 3 8.654 3 10c0 2.992 2.317 5.35 5.334 6.397A4 4 0 0 0 8 17.98l-.168.034c-.717.099-1.176.01-1.488-.122c-.76-.322-1.152-1.133-1.63-1.753c-.298-.385-.732-.866-1.398-1.088a1 1 0 0 0-.632 1.898c.558.186.944 1.142 1.298 1.566c.373.448.869.916 1.58 1.218c.682.29 1.483.393 2.438.276V21a1 1 0 0 0 2 0v-3c0-.574.24-1.09.629-1.456c.607-.572.297-1.568-.495-1.726C6.969 14.187 5 12.114 5 10c0-.958.385-1.881 1.108-2.684c.283-.314.357-.756.207-1.14"
/>
</g>
</svg>
</a>
</header>
<p>A Spicetify extension to integrate your Jellyfin music library into Spotify</p>
<hr />
<h3>Features</h3>
<ul>
<li>Stream music from Jellyfin instead of Spotify</li>
<li>Play tracks that exist on Jellyfin but aren't available on Spotify</li>
<li>Easily toggle between Jellyfin and Spotify audio</li>
</ul>
<hr />
<h3 style="margin-bottom: 1rem">Downloads</h3>
<a href="stable/jellyfin-spicetify.js" download class="button" style="margin-right: 2px">Stable</a>
<a href="unstable/jellyfin-spicetify.js" download class="button secondary">Beta (latest commit)</a>
<p class="marketplace-note">Also available on the Spicetify Marketplace!</p>
<hr />
<div class="help-container">
<div class="help-text-block">
<h3>Need help?</h3>
<p>Found a bug or want to request a feature?</p>
</div>
<a href="https://github.com/trafficlunar/jellyfin-spicetify/issues" class="button secondary">Report an Issue</a>
</div>
<hr />
<footer>
<a href="https://trafficlunar.net" id="author">Made by <b>trafficlunar</b></a>
<a href="https://ko-fi.com/trafficlunar" class="button">Support on Ko-fi</a>
</footer>
</main>
</body>
</html>

View file

@ -1,6 +1,10 @@
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api"; import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api/playstate-api";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import Fuse from "fuse.js";
import * as jellyfin from "./jellyfin"; import * as jellyfin from "./jellyfin";
import * as search from "./search";
import { settings } from "./settingsStore"; import { settings } from "./settingsStore";
import { signal } from "./utils"; import { signal } from "./utils";
@ -8,13 +12,12 @@ export const audio = new Audio();
export const canUseJellyfin = signal(false); export const canUseJellyfin = signal(false);
export let hijackActive = signal(false); export let hijackActive = signal(false);
export let currentVolume = Spicetify.Player.getVolume() || 0.5; export let currentVolume = Spicetify.Player.getVolume() || 0.5;
let currentItemId: string | null = null; let currentItemId: string | null = null;
let oldTime = 0; let oldTime = 0;
let lastProgressReport = 0; let lastProgressReport = 0;
// no signal because we don't need to subscribe to it
export function setOldTime(value: number) { export function setOldTime(value: number) {
// no signal because we don't need to subscribe to it
oldTime = value; oldTime = value;
} }
@ -99,7 +102,36 @@ export function registerEvents() {
currentItemId = null; currentItemId = null;
} }
const track = await search.findTracks(event.data.item.name, (event.data.item.artists?.map((a) => a.name) ?? ["Unknown artists"]).join(", ")); const trackName = event.data.item.name;
const searchResults = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: trackName,
includeItemTypes: [BaseItemKind.Audio],
limit: 32,
});
if (!searchResults.data.SearchHints || searchResults.data.SearchHints.length === 0) {
stop();
return;
}
// Fuzzy search
const artists = event.data.item.artists?.map((a) => a.name).join(" ") ?? "";
const list = searchResults.data.SearchHints.map((v) => ({
id: v.Id ?? "",
name: v.Name ?? "Unknown title",
artists: (v.Artists ?? ["Unknown artist"]).join(" "),
}));
const results = new Fuse(list, {
keys: ["name", "artists"],
threshold: 0.75,
}).search(`${trackName} ${artists}`);
console.debug(`[Jellyfin]: Query is "${trackName} ${artists}"`);
console.debug("[Jellyfin]: Search list:", list);
console.debug("[Jellyfin]: Fuse search found:", results);
const track = results[0]?.item;
if (!track) { if (!track) {
stop(); stop();
return; return;
@ -176,7 +208,7 @@ export function registerEvents() {
if (hijackActive.get()) { if (hijackActive.get()) {
console.debug("[Jellyfin]: Volume is", currentVolume); console.debug("[Jellyfin]: Volume is", currentVolume);
audio.volume = Math.pow(currentVolume, 3) * 0.425; // hopefully accurate audio.volume = Math.pow(currentVolume, 3) * 0.425;
if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`); if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`);
if (volumeSliderInput) volumeSliderInput.value = currentVolume.toString(); if (volumeSliderInput) volumeSliderInput.value = currentVolume.toString();
if (volumeIcon) volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()]; if (volumeIcon) volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
@ -224,13 +256,16 @@ export function registerEvents() {
// Similar, but for volume icon (tries to show up as muted) // Similar, but for volume icon (tries to show up as muted)
if (volumeIcon) { if (volumeIcon) {
let currentIcon = "";
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (!hijackActive.get()) return; if (!hijackActive.get()) return;
observer.disconnect(); // prevent re-triggering while we update const expectedIcon = getExpectedVolumeIcon();
volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()]; if (currentIcon === expectedIcon) return;
console.debug("[Jellyfin]: Attempted to set volume icon (observer)"); observer.disconnect(); // prevent re-triggering while we update
currentIcon = expectedIcon;
volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()];
observer.observe(volumeIcon, { childList: true, subtree: true }); observer.observe(volumeIcon, { childList: true, subtree: true });
}); });

View file

@ -1,71 +1,9 @@
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import Fuse from "fuse.js";
import * as jellyfin from "./jellyfin"; import * as jellyfin from "./jellyfin";
import * as player from "./player"; import * as player from "./player";
import { settings } from "./settingsStore"; import { settings } from "./settingsStore";
interface TrackResult {
id: string;
name: string;
artists: string;
runtime: number;
}
export async function findTracks(name: string, artists: string): Promise<TrackResult | undefined>;
export async function findTracks(name: string, artists: string, limit: number): Promise<TrackResult[]>;
export async function findTracks(name: string, artists: string, limit: number = 1): Promise<TrackResult | TrackResult[] | undefined> {
if (!jellyfin.api) return;
// Search multiple queries
// Tries full track name then every word in the name that is longer than 3 characters
const queries: string[] = [name, ...name.split(" ").filter((w) => w.length >= 3)];
const list = new Map<string, TrackResult>();
// Search Jellyfin for each query
for (const q of queries) {
const response = await getSearchApi(jellyfin.api).getSearchHints({
searchTerm: q,
includeItemTypes: [BaseItemKind.Audio],
limit: 48,
});
if (!response.data.SearchHints) continue;
response.data.SearchHints.map((v) => {
list.set(v.Id!, {
id: v.Id ?? "",
name: v.Name ?? "Unknown title",
artists: (v.Artists ?? ["Unknown artist"]).join(" "),
runtime: (v.RunTimeTicks ?? 100000) / 10000, // Show 10 seconds as default
});
});
}
// No queries found
if (list.size === 0) return;
// Fuzzy search
const results = new Fuse([...list.values()], {
includeScore: true,
keys: [
{ name: "name", weight: 0.75 },
{ name: "artists", weight: 0.4 },
],
threshold: 0.5,
}).search({ $or: [{ name }, { artists }] });
console.debug(`[Jellyfin]: Query is "${name} ${artists}"`);
console.debug("[Jellyfin]: Search list:", list);
console.debug("[Jellyfin]: Fuse search found:", results);
if (results.length === 0) return;
if (limit === 1) return results[0].item;
return results.map((v) => v.item);
}
// Add Jellyfin tracks to search (usually for songs not available on Spotify) // Add Jellyfin tracks to search (usually for songs not available on Spotify)
export function init() { export function init() {
Spicetify.Platform.History.listen(async (location) => { Spicetify.Platform.History.listen(async (location) => {
@ -74,8 +12,16 @@ export function init() {
if (!location.pathname.startsWith("/search/")) return; if (!location.pathname.startsWith("/search/")) return;
const segments = location.pathname.split("/"); const segments = location.pathname.split("/");
const tracks = await findTracks(segments[2], "", 4); const query = segments[2];
if (!tracks) return;
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]; const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1];
if (!parent) return; if (!parent) return;
@ -84,7 +30,7 @@ export function init() {
const template = parent.querySelector<HTMLDivElement>("div"); const template = parent.querySelector<HTMLDivElement>("div");
if (!template) return; if (!template) return;
tracks.forEach((trackInfo) => { searchHints.forEach((trackInfo) => {
// TODO: Skip if Spotify already has this track in its results (it will be hijacked instead) // TODO: Skip if Spotify already has this track in its results (it will be hijacked instead)
const track = template.cloneNode(true) as HTMLDivElement; const track = template.cloneNode(true) as HTMLDivElement;
@ -98,7 +44,7 @@ export function init() {
const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued"); const duration = sectionEnd?.querySelector(".encore-internal-color-text-subdued");
const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement; const contextMenuButton = sectionEnd?.lastElementChild as HTMLButtonElement;
if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.id) return; if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.Id) return;
// Remove all children of sectionEnd except duration and context menu button // Remove all children of sectionEnd except duration and context menu button
duration.id = "dontdelete"; duration.id = "dontdelete";
@ -114,22 +60,24 @@ export function init() {
contextMenuButton.style.cursor = "auto"; contextMenuButton.style.cursor = "auto";
// TODO: fallback image // TODO: fallback image
albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution albumCover.src = `${jellyfin.api?.basePath}/Items/${trackInfo.Id}/Images/Primary?fillHeight=40&fillWidth=40&quality=96`; // Aim for 40x40 resolution
albumCover.srcset = ""; albumCover.srcset = "";
songTitle.textContent = trackInfo.name; songTitle.textContent = trackInfo.Name ?? "Unknown title";
songArtist.innerHTML = ""; // Remove hyperlink to artist page songArtist.innerHTML = ""; // Remove hyperlink to artist page
songArtist.textContent = trackInfo.artists ?? "Unknown artist"; songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist";
// Set duration text // Set duration text
const durationMs = trackInfo.runtime / 10000; if (trackInfo.RunTimeTicks) {
const minutes = Math.floor(durationMs / 60000); const durationMs = trackInfo.RunTimeTicks / 10000;
const seconds = Math.floor((durationMs % 60000) / 1000); const minutes = Math.floor(durationMs / 60000);
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`; const seconds = Math.floor((durationMs % 60000) / 1000);
duration.textContent = `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
track.addEventListener("dblclick", () => { track.addEventListener("dblclick", () => {
Spicetify.Player.pause(); Spicetify.Player.pause();
// TODO: hijack player html // TODO: hijack player html
player.playTrack(trackInfo.id); player.playTrack(trackInfo.Id!);
}); });
parent.insertBefore(track, parent.firstChild); parent.insertBefore(track, parent.firstChild);