diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index c5cff90..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -ko_fi: trafficlunar diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a9a2a0..17d5b9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,61 +1,16 @@ name: build - -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 +on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v6 - with: - version: latest - - uses: actions/setup-node@v4 - with: - node-version: latest - cache: pnpm - - - run: pnpm install --frozen-lockfile - - run: pnpm build-local - + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run build-local - uses: actions/upload-artifact@v7 with: name: jellyfin-spicetify path: dist/jellyfin-spicetify.js - 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 + # archive: false diff --git a/README.md b/README.md index db7bae3..65091f2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ # jellyfin-spicetify -> A Spicetify extension to integrate your Jellyfin music library into Spotify +WIP: Spicetify extension to integrate your Jellyfin music library into Spotify -### [Go to the website](https://jellyfin-spicetify.trafficlunar.net) +## Downloads -Stable/beta downloads are on the site + releases (stable). +| Version | Description | Links | +| :------------- | :---------------------------------------- | :------------------------------------------------------------------------------------------------------- | +| ~~**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 diff --git a/build.ts b/build.ts index a837b35..f888357 100644 --- a/build.ts +++ b/build.ts @@ -33,7 +33,6 @@ const options: BuildOptions = { name: "on-end", setup(build) { build.onEnd(() => { - // Bundle the CSS into the final JS file const js = readFileSync("./dist/jellyfin-spicetify.js", "utf-8"); const css = readFileSync("./dist/jellyfin-spicetify.css", "utf-8"); diff --git a/site/index.html b/site/index.html deleted file mode 100644 index 48c7adf..0000000 --- a/site/index.html +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - jellyfin-spicetify - - - - - - - - -
-
- - - - - - - - - - - -

jellyfin-spicetify

- - - - - - - - - - -
-

A Spicetify extension to integrate your Jellyfin music library into Spotify

- -
-

Features

- - -
-

Downloads

- Stable - Beta (latest commit) -

Also available on the Spicetify Marketplace!

- -
-
-
-

Need help?

-

Found a bug or want to request a feature?

-
- - Report an Issue -
- -
- -
- - diff --git a/src/player.ts b/src/player.ts index 0502ed1..40095f3 100644 --- a/src/player.ts +++ b/src/player.ts @@ -1,10 +1,6 @@ -import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-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 search from "./search"; import { settings } from "./settingsStore"; import { signal } from "./utils"; @@ -12,12 +8,13 @@ export const audio = new Audio(); export const canUseJellyfin = signal(false); export let hijackActive = signal(false); export let currentVolume = Spicetify.Player.getVolume() || 0.5; + let currentItemId: string | null = null; let oldTime = 0; let lastProgressReport = 0; +// no signal because we don't need to subscribe to it export function setOldTime(value: number) { - // no signal because we don't need to subscribe to it oldTime = value; } @@ -102,36 +99,7 @@ export function registerEvents() { currentItemId = null; } - 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; + const track = await search.findTracks(event.data.item.name, (event.data.item.artists?.map((a) => a.name) ?? ["Unknown artists"]).join(", ")); if (!track) { stop(); return; @@ -208,7 +176,7 @@ export function registerEvents() { if (hijackActive.get()) { console.debug("[Jellyfin]: Volume is", currentVolume); - audio.volume = Math.pow(currentVolume, 3) * 0.425; + audio.volume = Math.pow(currentVolume, 3) * 0.425; // hopefully accurate if (volumeSlider) volumeSlider.style.setProperty("--progress-bar-transform", `${currentVolume * 100}%`); if (volumeSliderInput) volumeSliderInput.value = currentVolume.toString(); if (volumeIcon) volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()]; @@ -256,16 +224,13 @@ export function registerEvents() { // Similar, but for volume icon (tries to show up as muted) if (volumeIcon) { - let currentIcon = ""; const observer = new MutationObserver(() => { if (!hijackActive.get()) return; - const expectedIcon = getExpectedVolumeIcon(); - if (currentIcon === expectedIcon) return; - observer.disconnect(); // prevent re-triggering while we update - currentIcon = expectedIcon; volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()]; + + console.debug("[Jellyfin]: Attempted to set volume icon (observer)"); observer.observe(volumeIcon, { childList: true, subtree: true }); }); diff --git a/src/search.ts b/src/search.ts index 6f88d0b..21d37d2 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,9 +1,71 @@ import { getSearchApi } from "@jellyfin/sdk/lib/utils/api/search-api"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; + +import Fuse from "fuse.js"; + import * as jellyfin from "./jellyfin"; import * as player from "./player"; import { settings } from "./settingsStore"; +interface TrackResult { + id: string; + name: string; + artists: string; + runtime: number; +} + +export async function findTracks(name: string, artists: string): Promise; +export async function findTracks(name: string, artists: string, limit: number): Promise; +export async function findTracks(name: string, artists: string, limit: number = 1): Promise { + 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(); + + // 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) export function init() { Spicetify.Platform.History.listen(async (location) => { @@ -12,16 +74,8 @@ export function init() { 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 tracks = await findTracks(segments[2], "", 4); + if (!tracks) return; const parent = document.querySelectorAll(".main-trackList-trackList > div > div")[1]; if (!parent) return; @@ -30,7 +84,7 @@ export function init() { const template = parent.querySelector("div"); if (!template) return; - searchHints.forEach((trackInfo) => { + tracks.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; @@ -44,7 +98,7 @@ export function init() { 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; + if (!albumCover || !songTitle || !songArtist || !duration || !sectionEnd || !contextMenuButton || !trackInfo.id) return; // Remove all children of sectionEnd except duration and context menu button duration.id = "dontdelete"; @@ -60,24 +114,22 @@ export function init() { contextMenuButton.style.cursor = "auto"; // 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 = ""; - songTitle.textContent = trackInfo.Name ?? "Unknown title"; + songTitle.textContent = trackInfo.name; songArtist.innerHTML = ""; // Remove hyperlink to artist page - songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist"; + songArtist.textContent = trackInfo.artists ?? "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")}`; - } + const durationMs = trackInfo.runtime / 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!); + player.playTrack(trackInfo.id); }); parent.insertBefore(track, parent.firstChild);