From aba1c80fb4ecb659503845c154258ab148dfe088 Mon Sep 17 00:00:00 2001 From: Niko Huuskonen Date: Thu, 27 Aug 2026 02:13:43 +0300 Subject: [PATCH 01/10] feat: add Jellyfin media source integration - Implemented Jellyfin media source functionality, allowing users to connect to Jellyfin servers for music streaming - Added localization strings for Jellyfin settings in multiple languages - Created JellyfinMediaSource class to handle authentication, media fetching, and syncing - Updated MediaSourceManager to support adding and managing Jellyfin sources - Enhanced player and track logic to accommodate Jellyfin tracks and their metadata - Introduced UI components for managing Jellyfin servers in the settings view Signed-off-by: Niko Huuskonen --- src/renderer/components/LazyList.vue | 5 + .../OutputDiagram/blobs/SourceBlob.vue | 5 + src/renderer/locales/af-ZA.json | 13 +- src/renderer/locales/ar-SA.json | 13 +- src/renderer/locales/ca-ES.json | 13 +- src/renderer/locales/cs-CZ.json | 13 +- src/renderer/locales/da-DK.json | 13 +- src/renderer/locales/de-DE.json | 13 +- src/renderer/locales/el-GR.json | 13 +- src/renderer/locales/en-US.json | 9 + src/renderer/locales/es-ES.json | 13 +- src/renderer/locales/fi-FI.json | 13 +- src/renderer/locales/fr-FR.json | 13 +- src/renderer/locales/he-IL.json | 13 +- src/renderer/locales/hu-HU.json | 13 +- src/renderer/locales/it-IT.json | 13 +- src/renderer/locales/ja-JP.json | 13 +- src/renderer/locales/ko-KR.json | 13 +- src/renderer/locales/nl-NL.json | 13 +- src/renderer/locales/no-NO.json | 13 +- src/renderer/locales/pl-PL.json | 13 +- src/renderer/locales/pt-BR.json | 13 +- src/renderer/locales/pt-PT.json | 13 +- src/renderer/locales/ro-RO.json | 13 +- src/renderer/locales/ru-RU.json | 13 +- src/renderer/locales/sr-SP.json | 13 +- src/renderer/locales/sv-SE.json | 13 +- src/renderer/locales/tr-TR.json | 13 +- src/renderer/locales/uk-UA.json | 13 +- src/renderer/locales/vi-VN.json | 13 +- src/renderer/locales/zh-CN.json | 13 +- src/renderer/locales/zh-TW.json | 13 +- .../logic/MediaSource/JellyfinMediaSource.ts | 234 ++++++++++++++++++ .../logic/MediaSource/MediaSourceManager.ts | 19 ++ src/renderer/logic/MediaSource/index.ts | 1 + src/renderer/logic/player.ts | 2 +- src/renderer/logic/queue.ts | 4 + src/renderer/logic/track.ts | 30 ++- .../views/Settings/MediaSourceSettings.vue | 87 +++++++ 39 files changed, 709 insertions(+), 64 deletions(-) create mode 100644 src/renderer/logic/MediaSource/JellyfinMediaSource.ts diff --git a/src/renderer/components/LazyList.vue b/src/renderer/components/LazyList.vue index d4ec41b5..5a0a5228 100644 --- a/src/renderer/components/LazyList.vue +++ b/src/renderer/components/LazyList.vue @@ -556,6 +556,11 @@ const handleColumnContextMenu = ({ x, y }: MouseEvent) => { icon="tabler:submarine" class="h-4 w-4" /> + { class="h-6 w-6 text-text-title" icon="tabler:submarine" /> + = ref(false); + public isSyncing: Ref = ref(false); + public ping: Ref = ref(null); + public syncStatus: Ref = ref("Idle"); + private shouldStopSync = false; + private userId: string | undefined; + private accessToken: string = ""; + + public serverInformation: JellyfinPublicSystemInfo | undefined; + + public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string) { + super(amethyst, url); + this.type = MediaSourceType.Jellyfin; + this.name = this.url; + + this.initialize(); + } + + private async initialize() { + this.isConnected.value = await this.testConnection(); + if (!this.isConnected.value) { + console.error("Failed to connect to Jellyfin server"); + return; + } + + this.isConnected.value = await this.authenticate(); + if (!this.isConnected.value) { + console.error("Failed to authenticate with Jellyfin server"); + return; + } + + this.sync(); + } + + // Builds the "MediaBrowser" authorization scheme Jellyfin expects on every request + private authorizationHeader(token = "") { + return [ + `MediaBrowser Client="${encodeURIComponent(CLIENT_NAME)}"`, + `Device="${encodeURIComponent(CLIENT_NAME)}"`, + `DeviceId="${encodeURIComponent(this.uuid)}"`, + `Version="${encodeURIComponent(CLIENT_VERSION)}"`, + `Token="${encodeURIComponent(token)}"`, + ].join(", "); + } + + private testConnection = async (): Promise => { + try { + const start = performance.now(); + const response = await fetch(`${this.url}/System/Info/Public`); + if (!response.ok) return false; + this.ping.value = Math.round(performance.now() - start); + this.serverInformation = await response.json(); + return true; + } + catch (error) { + return false; + } + }; + + private authenticate = async (): Promise => { + try { + const response = await fetch(`${this.url}/Users/AuthenticateByName`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": this.authorizationHeader(), + }, + body: JSON.stringify({ Username: this.username, Pw: this.password }), + }); + if (!response.ok) return false; + + const data: JellyfinAuthenticationResult = await response.json(); + this.accessToken = data.AccessToken ?? ""; + this.userId = data.User?.Id; + return !!this.userId && !!this.accessToken; + } + catch (error) { + return false; + } + }; + + public sync = async (): Promise => { + await this.fetchMedia(); + }; + + public stopSync = (): void => { + this.shouldStopSync = true; + }; + + public override async fetchMedia() { + if (this.isSyncing.value || !this.userId) return; + + this.isSyncing.value = true; + this.syncStatus.value = "Starting sync..."; + + // Jellyfin issues a fresh access token (and therefore a fresh stream url) every time we + // authenticate, so previously synced tracks from this server have to be replaced rather + // than deduplicated by path like local/subsonic sources are. + this.amethyst.player.queue.getList() + .filter((track) => track.sourceType == MediaSourceType.Jellyfin && (!track.credentials || track.credentials.url == this.url)) + .forEach((track) => this.amethyst.player.queue.remove(track)); + + let startIndex = 0; + let totalCount = Infinity; + + while (startIndex < totalCount) { + const params = new URLSearchParams({ + userId: this.userId, + includeItemTypes: "Audio", + recursive: "true", + fields: "MediaSources", + startIndex: String(startIndex), + limit: String(PAGE_SIZE), + }); + + const response = await fetch(`${this.url}/Items?${params}`, { + headers: { Authorization: this.authorizationHeader(this.accessToken) }, + }); + + if (!response.ok) break; + + const data: JellyfinItemsResponse = await response.json(); + totalCount = data.TotalRecordCount ?? 0; + const items = data.Items ?? []; + if (items.length === 0) break; + + for (const item of items) { + this.syncStatus.value = `Fetching track: ${item.Name}`; + this.amethyst.player.queue.add(this.createTrackFromJellyfinItem(item)); + + if (this.shouldStopSync) { + this.shouldStopSync = false; + this.isSyncing.value = false; + return; + } + } + + startIndex += PAGE_SIZE; + } + + this.isSyncing.value = false; + } + + private buildStreamUrl(itemId: string) { + return `${this.url}/Audio/${itemId}/stream?static=true&api_key=${this.accessToken}`; + } + + private buildImageUrl(itemId: string, tag: string) { + return `${this.url}/Items/${itemId}/Images/Primary?fillWidth=128&fillHeight=128&quality=90&tag=${tag}&api_key=${this.accessToken}`; + } + + private createTrackFromJellyfinItem(item: JellyfinItem): Track { + const path = this.buildStreamUrl(item.Id!); + const track = new Track(this.amethyst, path); + + track.sourceType = MediaSourceType.Jellyfin; + track.jellyfinTrackId = item.Id; + track.credentials = { url: this.url, userId: this.userId, accessToken: this.accessToken }; + track.setTitle(item.Name ?? ""); + + item.ImageTags?.Primary && track.setCoverArt(this.buildImageUrl(item.Id!, item.ImageTags.Primary)); + item.Album && track.setAlbum(item.Album); + item.Artists?.length && track.setArtists(item.Artists); + + const mediaSource = item.MediaSources?.[0]; + mediaSource?.Size && track.setSize(mediaSource.Size); + mediaSource?.Bitrate && track.setBitRate(mediaSource.Bitrate); + mediaSource?.Container && track.setMimeType(mediaSource.Container); + + item.RunTimeTicks && track.setDuration(item.RunTimeTicks / 10_000_000); + item.ParentIndexNumber && track.setDiscNumber(item.ParentIndexNumber); + item.IndexNumber && track.setTrackNumber(item.IndexNumber); + item.ProductionYear && track.setYear(item.ProductionYear); + item.UserData?.IsFavorite && track.setIsFavorite(true); + + track.isLoading.value = false; + track.isLoaded.value = true; + + return track; + } + + public override register() { + } + + public override unregister() { + } +} diff --git a/src/renderer/logic/MediaSource/MediaSourceManager.ts b/src/renderer/logic/MediaSource/MediaSourceManager.ts index 9e97bdef..2932e3c4 100644 --- a/src/renderer/logic/MediaSource/MediaSourceManager.ts +++ b/src/renderer/logic/MediaSource/MediaSourceManager.ts @@ -2,6 +2,7 @@ import { ref } from "vue"; import { Amethyst } from "@/amethyst.js"; import { MediaSource, MediaSourceType } from "@/logic//MediaSource/index.js"; +import { JellyfinMediaSource } from "@/logic//MediaSource/JellyfinMediaSource.js"; import { LocalMediaSource } from "@/logic//MediaSource/LocalMediaSource.js"; import { SubsonicMediaSource } from "@/logic//MediaSource/SubsonicMediaSource.js"; @@ -19,6 +20,11 @@ export class MediaSourceManager { // @ts-ignore this.mediaSources.value.push(new SubsonicMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password)); } + + if (savedSource.type == MediaSourceType.Jellyfin) { + // @ts-ignore + this.mediaSources.value.push(new JellyfinMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password)); + } }); } @@ -54,6 +60,19 @@ export class MediaSourceManager { this.mediaSources.value.push(mediaSource); }; + public addJellyfinSource = async (url: string, username: string, password: string) => { + const mediaSource = new JellyfinMediaSource(this.amethyst, url, username, password); + + this.amethyst.state.settings.mediaSources.saveMediaSources.push({ + type: mediaSource.type, + url: mediaSource.url, + username: mediaSource.username, + password: mediaSource.password, + }); + // @ts-ignore + this.mediaSources.value.push(mediaSource); + }; + public removeMediaSource = async (mediaSource: MediaSource) => { const savedMediaSource = { type: mediaSource.type, path: mediaSource.path, uuid: mediaSource.uuid }; const index = this.mediaSources.value.findIndex((s) => s.uuid == savedMediaSource.uuid); diff --git a/src/renderer/logic/MediaSource/index.ts b/src/renderer/logic/MediaSource/index.ts index 731c13c6..c7aee37f 100644 --- a/src/renderer/logic/MediaSource/index.ts +++ b/src/renderer/logic/MediaSource/index.ts @@ -7,6 +7,7 @@ import { Amethyst } from "@/amethyst.js"; export enum MediaSourceType { LocalFolder = "settings.media_source_type.local_folder", Subsonic = "settings.media_source_type.subsonic", + Jellyfin = "settings.media_source_type.jellyfin", Local = "settings.media_source_type.local", } diff --git a/src/renderer/logic/player.ts b/src/renderer/logic/player.ts index 5902ad5e..08db987d 100644 --- a/src/renderer/logic/player.ts +++ b/src/renderer/logic/player.ts @@ -122,7 +122,7 @@ export class Player extends EventEmitter { private async setPlayingTrack(track: Track) { this.timeStarted.value = Math.floor(Date.now() / 1000); - this.input.src = ["mac", "linux"].includes(this.amethyst.getCurrentOperatingSystem()) ? `file://${track.path}` : track.path; + this.input.src = !track.isRemoteSource() && ["mac", "linux"].includes(this.amethyst.getCurrentOperatingSystem()) ? `file://${track.path}` : track.path; this.input.preservesPitch = false; this.setPlaybackSpeed(this.pitchSemitones.value); this.currentTrack.value = track; diff --git a/src/renderer/logic/queue.ts b/src/renderer/logic/queue.ts index 9c59415d..f427bf62 100644 --- a/src/renderer/logic/queue.ts +++ b/src/renderer/logic/queue.ts @@ -67,6 +67,10 @@ export class Queue { track.sourceType = MediaSourceType.Subsonic; } + if (item.type == MediaSourceType.Jellyfin) { + track.sourceType = MediaSourceType.Jellyfin; + } + this.add(track); }); } diff --git a/src/renderer/logic/track.ts b/src/renderer/logic/track.ts index edc25320..f90e0303 100644 --- a/src/renderer/logic/track.ts +++ b/src/renderer/logic/track.ts @@ -59,10 +59,18 @@ export class Track { public sourceType: MediaSourceType = MediaSourceType.Local; + /** + * @returns Whether this track streams from a remote server (Subsonic/Jellyfin) rather than a local file + */ + public isRemoteSource() { + return this.sourceType == MediaSourceType.Subsonic || this.sourceType == MediaSourceType.Jellyfin; + } + // new stuff for refactoring public coverUrl: string = ""; public subsonicTrackId?: string = ""; - public credentials?: { username: string; password: string; url: string }; + public jellyfinTrackId?: string = ""; + public credentials?: { username?: string; password?: string; url: string; userId?: string; accessToken?: string }; public title: string = ""; public duration: number = 0; public album: string = ""; @@ -102,6 +110,13 @@ export class Track { }); }; + if (this.sourceType == MediaSourceType.Jellyfin) { + const url = `${this.credentials!.url}/UserFavoriteItems/${this.jellyfinTrackId}?userId=${this.credentials!.userId}&api_key=${this.credentials!.accessToken}`; + fetch(url, { method: this.isFavorited ? "POST" : "DELETE" }).catch((error) => { + console.error("Failed to toggle favorite status on Jellyfin server:", error); + }); + }; + console.log(this.uuid); if (this.isFavorited) { favoriteTracks.value.push(this.uuid!); @@ -112,7 +127,12 @@ export class Track { } public getCachePath(absolute?: boolean) { - const amfPath = window.path.join(this.amethyst.APPDATA_PATH || "", "/amethyst/Metadata Cache", (this.sourceType == MediaSourceType.Subsonic ? this.subsonicTrackId! : this.getFilename()) + ".amf"); + const remoteTrackId = this.sourceType == MediaSourceType.Subsonic + ? this.subsonicTrackId + : this.sourceType == MediaSourceType.Jellyfin + ? this.jellyfinTrackId + : undefined; + const amfPath = window.path.join(this.amethyst.APPDATA_PATH || "", "/amethyst/Metadata Cache", (remoteTrackId ?? this.getFilename()) + ".amf"); return absolute ? amfPath : `file://${amfPath}`; } @@ -157,7 +177,7 @@ export class Track { * Reads track metadata from disk */ private async readMetadata() { - if (this.sourceType == MediaSourceType.Subsonic) return; + if (this.sourceType == MediaSourceType.Subsonic || this.sourceType == MediaSourceType.Jellyfin) return; switch (this.amethyst.getCurrentPlatform()) { case "desktop": @@ -174,7 +194,7 @@ export class Track { } private async readCover() { - if (this.sourceType == MediaSourceType.Subsonic) return; + if (this.sourceType == MediaSourceType.Subsonic || this.sourceType == MediaSourceType.Jellyfin) return; switch (this.amethyst.getCurrentPlatform()) { case "desktop": @@ -314,7 +334,7 @@ export class Track { }; } - if (this.sourceType != MediaSourceType.Subsonic) { + if (this.sourceType != MediaSourceType.Subsonic && this.sourceType != MediaSourceType.Jellyfin) { const [cover, metadata] = await Promise.all([this.fetchCover(force, cachedData.cover), this.fetchMetadata(force, cachedData.metadata)]); if (metadata) { diff --git a/src/renderer/views/Settings/MediaSourceSettings.vue b/src/renderer/views/Settings/MediaSourceSettings.vue index 18b1e775..adfc5c66 100644 --- a/src/renderer/views/Settings/MediaSourceSettings.vue +++ b/src/renderer/views/Settings/MediaSourceSettings.vue @@ -6,11 +6,13 @@ import BaseForm from "@/components/BaseForm.vue"; import SettingsSetting from "@/components/settings/SettingsSetting.vue"; import ButtonInput from "@/components/v2/ButtonInput.vue"; import { MediaSourceType } from "@/logic/MediaSource"; +import { JellyfinMediaSource } from "@/logic/MediaSource/JellyfinMediaSource"; import { LocalMediaSource } from "@/logic/MediaSource/LocalMediaSource"; import { SubsonicMediaSource } from "@/logic/MediaSource/SubsonicMediaSource"; import { ref } from "vue"; const showAddServerForm = ref(false); +const showAddJellyfinServerForm = ref(false); @@ -151,6 +153,91 @@ const showAddServerForm = ref(false); + + + + + + + + + + + +