diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c5cff90 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: trafficlunar diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17d5b9f..2a9a2a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,16 +1,61 @@ 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: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - run: bun install - - run: bun run build-local + - 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: actions/upload-artifact@v7 with: name: jellyfin-spicetify 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 diff --git a/README.md b/README.md index 65091f2..db7bae3 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,10 @@ # 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**~~ | ~~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 +Stable/beta downloads are on the site + releases (stable). ## Known Limitations diff --git a/build.ts b/build.ts index f888357..a837b35 100644 --- a/build.ts +++ b/build.ts @@ -33,6 +33,7 @@ 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 new file mode 100644 index 0000000..48c7adf --- /dev/null +++ b/site/index.html @@ -0,0 +1,260 @@ + + + + + + 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 40095f3..0502ed1 100644 --- a/src/player.ts +++ b/src/player.ts @@ -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 { 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"; @@ -8,13 +12,12 @@ 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; } @@ -99,7 +102,36 @@ export function registerEvents() { 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) { stop(); return; @@ -176,7 +208,7 @@ export function registerEvents() { if (hijackActive.get()) { 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 (volumeSliderInput) volumeSliderInput.value = currentVolume.toString(); 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) if (volumeIcon) { + let currentIcon = ""; const observer = new MutationObserver(() => { if (!hijackActive.get()) return; - observer.disconnect(); // prevent re-triggering while we update - volumeIcon.innerHTML = VOLUME_ICONS[getExpectedVolumeIcon()]; + const expectedIcon = 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 }); }); diff --git a/src/search.ts b/src/search.ts index 21d37d2..6f88d0b 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,71 +1,9 @@ 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) => { @@ -74,8 +12,16 @@ export function init() { if (!location.pathname.startsWith("/search/")) return; const segments = location.pathname.split("/"); - const tracks = await findTracks(segments[2], "", 4); - if (!tracks) return; + 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; @@ -84,7 +30,7 @@ export function init() { const template = parent.querySelector("div"); 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) const track = template.cloneNode(true) as HTMLDivElement; @@ -98,7 +44,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"; @@ -114,22 +60,24 @@ 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; + songTitle.textContent = trackInfo.Name ?? "Unknown title"; songArtist.innerHTML = ""; // Remove hyperlink to artist page - songArtist.textContent = trackInfo.artists ?? "Unknown artist"; + songArtist.textContent = trackInfo.Artists?.join(", ") ?? "Unknown artist"; // Set duration text - 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")}`; + 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); + player.playTrack(trackInfo.Id!); }); parent.insertBefore(track, parent.firstChild);