diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44322ef4..60d5a9d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: submodules: recursive - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install gcc dependencies @@ -44,7 +44,7 @@ jobs: submodules: recursive - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install gcc dependencies @@ -68,7 +68,7 @@ jobs: submodules: recursive - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install Dependencies @@ -98,7 +98,7 @@ jobs: submodules: recursive - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install Dependencies @@ -139,7 +139,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install common Linux dependencies @@ -205,7 +205,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Reconstruct keystore from base64 @@ -310,7 +310,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20.17.0 + node-version: 24.20.0 cache: "yarn" - name: Install dependencies diff --git a/package.json b/package.json index ef18bb7d..e501c67c 100644 --- a/package.json +++ b/package.json @@ -409,9 +409,16 @@ "vue-virtual-scroller": "^2.0.0-beta.8" }, "devEngines": { - "node": ">=18", - "npm": ">=8.x", - "yarn": ">=1.22.x" + "runtime": { + "name": "node", + "version": ">=18", + "onFail": "warn" + }, + "packageManager": { + "name": "yarn", + "version": ">=1.22.0", + "onFail": "warn" + } }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/renderer/amethyst.ts b/src/renderer/amethyst.ts index 719e7ac3..6696e990 100644 --- a/src/renderer/amethyst.ts +++ b/src/renderer/amethyst.ts @@ -466,7 +466,7 @@ export class Amethyst extends AmethystBackend { start.toString(), (track.getDurationSeconds() as number).toString(), track.sourceType != MediaSourceType.Subsonic && track.coverUrl, - track.metadata.data?.format.container?.toLowerCase() || "unknown format", + track.getContainer()?.toLowerCase() || "unknown format", isPaused ? "yes" : "no", ]; window.electron.ipcRenderer.invoke("update-rich-presence", [args]); 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"); + public isScrobblingEnabled: Ref; + private shouldStopSync = false; + private userId: string | undefined; + private accessToken: string = ""; + private lastSyncedAt = 0; + private scrobbleState: { track: Track; playSessionId: string } | undefined; + private scrobbleProgressTimer: ReturnType | undefined; + private scrobbleUnsubscribers: (() => void)[] = []; + + public serverInformation: JellyfinPublicSystemInfo | undefined; + + public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, scrobble = true) { + super(amethyst, url); + this.type = MediaSourceType.Jellyfin; + this.name = this.url; + this.isScrobblingEnabled = ref(scrobble); + + // Persist toggling the switch in Settings back into the saved source entry + watch(this.isScrobblingEnabled, (enabled) => { + const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Jellyfin && s.url == this.url); + if (saved) saved.scrobble = enabled; + }); + + this.setupScrobbling(); + this.initialize(); + } + + private async initialize() { + this.isConnected.value = await this.testConnection(); + if (!this.isConnected.value) { + console.error("Failed to connect to Jellyfin server"); + return; + } + + const cache = await this.readCache(); + if (cache) { + this.accessToken = cache.accessToken; + this.userId = cache.userId; + this.lastSyncedAt = cache.lastSyncedAt; + this.hydrateFromCache(cache.items); + + if (await this.verifySession()) { + this.isConnected.value = true; + await this.sync(false); + return; + } + } + + // No usable cached session - log in fresh. Anything hydrated from a stale cache above + // gets reconciled (not duplicated) by the full sync below, since it upserts by item id + this.isConnected.value = await this.authenticate(); + if (!this.isConnected.value) { + console.error("Failed to authenticate with Jellyfin server"); + return; + } + + await this.sync(true); + } + + // 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(this.amethyst.VERSION)}"`, + `Token="${encodeURIComponent(token)}"`, + ].join(", "); + } + + /** + * Reports playback of tracks from this source to Jellyfin (via the same Sessions/Playing + * endpoints real clients use), so PlayCount/LastPlayedDate on the server reflect what's + * actually being listened to. Wires into the player's existing events once for the lifetime + * of this source; each handler is a no-op unless scrobbling is enabled and the track in + * question actually belongs to this server. + */ + private setupScrobbling() { + const player = this.amethyst.player; + + const belongsToThisSource = (track?: Track): track is Track => + !!track && track.sourceType == MediaSourceType.Jellyfin && track.credentials?.url == this.url; + + const stopProgressTimer = () => { + if (this.scrobbleProgressTimer) { + clearInterval(this.scrobbleProgressTimer); + this.scrobbleProgressTimer = undefined; + } + }; + + const startProgressTimer = () => { + stopProgressTimer(); + this.scrobbleProgressTimer = setInterval(() => { + if (!this.scrobbleState) return; + this.reportPlaybackProgress(this.scrobbleState.track, player.currentTime.value, player.isPaused.value, this.scrobbleState.playSessionId); + }, SCROBBLE_PROGRESS_INTERVAL_MS); + }; + + const stopScrobbling = (positionSeconds: number) => { + if (!this.scrobbleState) return; + const { track, playSessionId } = this.scrobbleState; + this.scrobbleState = undefined; + stopProgressTimer(); + this.reportPlaybackStopped(track, positionSeconds, playSessionId); + }; + + const startScrobbling = (track: Track) => { + if (!this.isScrobblingEnabled.value || !belongsToThisSource(track)) return; + + const playSessionId = uuidv4(); + this.scrobbleState = { track, playSessionId }; + this.reportPlaybackStart(track, playSessionId); + startProgressTimer(); + }; + + const onTrackFinished = (payload: PlayerEvents["player:trackFinished"]) => { + if (this.scrobbleState && payload.track === this.scrobbleState.track) { + stopScrobbling(player.currentTime.value); + } + }; + + const onTrackChange = (track: PlayerEvents["player:trackChange"]) => { + if (this.scrobbleState && this.scrobbleState.track !== track) { + // Previous track never got a trackFinished (e.g. the user jumped straight to a + // different one) - close out its session before opening a new one. + stopScrobbling(player.currentTime.value); + } + startScrobbling(track); + }; + + const onPause = () => { + if (!this.scrobbleState) return; + stopProgressTimer(); + this.reportPlaybackProgress(this.scrobbleState.track, player.currentTime.value, true, this.scrobbleState.playSessionId); + }; + + const onResume = () => { + if (!this.scrobbleState) return; + this.reportPlaybackProgress(this.scrobbleState.track, player.currentTime.value, false, this.scrobbleState.playSessionId); + startProgressTimer(); + }; + + const onSeek = (payload: PlayerEvents["player:seek"]) => { + if (!this.scrobbleState) return; + this.reportPlaybackProgress(this.scrobbleState.track, payload.seekedTo, player.isPaused.value, this.scrobbleState.playSessionId); + }; + + const onStop = () => { + stopScrobbling(player.currentTime.value); + }; + + player.on("player:trackFinished", onTrackFinished); + player.on("player:trackChange", onTrackChange); + player.on("player:pause", onPause); + player.on("player:resume", onResume); + player.on("player:seek", onSeek); + player.on("player:stop", onStop); + + this.scrobbleUnsubscribers.push( + () => player.off("player:trackFinished", onTrackFinished), + () => player.off("player:trackChange", onTrackChange), + () => player.off("player:pause", onPause), + () => player.off("player:resume", onResume), + () => player.off("player:seek", onSeek), + () => player.off("player:stop", onStop), + ); + } + + private async reportPlaybackStart(track: Track, playSessionId: string) { + if (!track.jellyfinTrackId) return; + try { + await fetch(`${this.url}/Sessions/Playing`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": this.authorizationHeader(this.accessToken) }, + body: JSON.stringify({ + ItemId: track.jellyfinTrackId, + PlaySessionId: playSessionId, + PositionTicks: 0, + IsPaused: false, + CanSeek: true, + PlayMethod: "DirectPlay", + }), + }); + + console.log(`%c[⚐ Jellyfin]%c Started scrobbling "${track.getTitleFormatted()}"`, "background-color: #00A4DC; color: black; font-weight: bold;", "color:rgb(150, 220, 255);"); + } + catch (error) { + console.error("Failed to report playback start to Jellyfin:", error); + } + } + + private async reportPlaybackProgress(track: Track, positionSeconds: number, isPaused: boolean, playSessionId: string) { + if (!track.jellyfinTrackId) return; + try { + await fetch(`${this.url}/Sessions/Playing/Progress`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": this.authorizationHeader(this.accessToken) }, + body: JSON.stringify({ + ItemId: track.jellyfinTrackId, + PlaySessionId: playSessionId, + PositionTicks: Math.round(positionSeconds * 10_000_000), + IsPaused: isPaused, + PlayMethod: "DirectPlay", + }), + }); + } + catch (error) { + console.error("Failed to report playback progress to Jellyfin:", error); + } + } + + private async reportPlaybackStopped(track: Track, positionSeconds: number, playSessionId: string) { + if (!track.jellyfinTrackId) return; + try { + await fetch(`${this.url}/Sessions/Playing/Stopped`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": this.authorizationHeader(this.accessToken) }, + body: JSON.stringify({ + ItemId: track.jellyfinTrackId, + PlaySessionId: playSessionId, + PositionTicks: Math.round(positionSeconds * 10_000_000), + }), + }); + + console.log(`%c[⚐ Jellyfin]%c Stopped scrobbling "${track.getTitleFormatted()}" at ${Math.round(positionSeconds)}s`, "background-color: #00A4DC; color: black; font-weight: bold;", "color:rgb(150, 220, 255);"); + } + catch (error) { + console.error("Failed to report playback stop to Jellyfin:", error); + } + } + + 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; + } + }; + + // Confirms a cached access token hasn't been revoked (password change, admin session kill, etc) + private verifySession = async (): Promise => { + if (!this.accessToken || !this.userId) return false; + try { + const response = await fetch(`${this.url}/Users/${this.userId}`, { + headers: { Authorization: this.authorizationHeader(this.accessToken) }, + }); + return response.ok; + } + 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; + } + }; + + /** + * @param full Whether to walk the entire library (also reconciles deletions) or only ask + * the server for items touched since the last sync. Defaults to a full sync since that's + * what a user-triggered "Sync" button should mean; startup uses `sync(false)` when a valid + * cached session is available. + */ + public sync = async (full = true): Promise => { + await this.fetchMedia(full); + }; + + public stopSync = (): void => { + this.shouldStopSync = true; + }; + + public override async fetchMedia(full = true) { + if (this.isSyncing.value || !this.userId) return; + const userId = this.userId; + + this.isSyncing.value = true; + this.syncStatus.value = full ? "Starting full sync..." : "Checking for updates..."; + + const existingById = new Map(); + this.amethyst.player.queue.getList().forEach((track) => { + if (track.sourceType == MediaSourceType.Jellyfin && track.credentials?.url == this.url && track.jellyfinTrackId) { + existingById.set(track.jellyfinTrackId, track); + } + }); + + const seenIds = new Set(); + const fetchedItems: JellyfinItem[] = []; + const syncStartedAt = Date.now(); + let startIndex = 0; + let totalCount = Infinity; + + while (startIndex < totalCount) { + const params = new URLSearchParams({ + userId, + includeItemTypes: "Audio", + recursive: "true", + fields: "MediaSources,MediaStreams", + startIndex: String(startIndex), + limit: String(PAGE_SIZE), + }); + + if (!full && this.lastSyncedAt) { + params.set("minDateLastSaved", new Date(this.lastSyncedAt - INCREMENTAL_SYNC_OVERLAP_MS).toISOString()); + } + + 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) { + if (!item.Id) continue; + + this.syncStatus.value = `Fetching track: ${item.Name}`; + seenIds.add(item.Id); + fetchedItems.push(item); + this.upsertTrack(item, existingById); + + if (this.shouldStopSync) { + this.shouldStopSync = false; + this.isSyncing.value = false; + return; + } + } + + startIndex += PAGE_SIZE; + } + + if (full) { + // Anything that belonged to this source but wasn't seen in this walk was removed on the server + existingById.forEach((track, id) => { + if (!seenIds.has(id)) this.amethyst.player.queue.remove(track); + }); + this.lastSyncedAt = syncStartedAt; + await this.writeCache({ accessToken: this.accessToken, userId, items: fetchedItems, lastSyncedAt: this.lastSyncedAt }); + } + else { + // Incremental syncs only see recently touched items, so merge them into the cached + // full snapshot instead of replacing it outright. + const previousItems = (await this.readCache())?.items ?? []; + const merged = new Map(previousItems.filter((item) => item.Id).map((item) => [item.Id!, item])); + fetchedItems.forEach((item) => merged.set(item.Id!, item)); + this.lastSyncedAt = syncStartedAt; + await this.writeCache({ accessToken: this.accessToken, userId, items: [...merged.values()], lastSyncedAt: this.lastSyncedAt }); + } + + this.isSyncing.value = false; + } + + private hydrateFromCache(items: JellyfinItem[]) { + items.forEach((item) => this.amethyst.player.queue.add(this.createTrackFromJellyfinItem(item))); + } + + private upsertTrack(item: JellyfinItem, existingById: Map) { + const existing = item.Id ? existingById.get(item.Id) : undefined; + if (existing) { + // Re-add under the (possibly new) path so the queue's path->track index stays accurate + this.amethyst.player.queue.remove(existing); + this.applyItemToTrack(existing, item); + this.amethyst.player.queue.add(existing); + } + else { + this.amethyst.player.queue.add(this.createTrackFromJellyfinItem(item)); + } + } + + 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 track = new Track(this.amethyst, this.buildStreamUrl(item.Id!)); + track.sourceType = MediaSourceType.Jellyfin; + this.applyItemToTrack(track, item); + + track.isLoading.value = false; + track.isLoaded.value = true; + + return track; + } + + private applyItemToTrack(track: Track, item: JellyfinItem) { + track.path = this.buildStreamUrl(item.Id!); + track.jellyfinTrackId = item.Id; + track.credentials = { url: this.url, userId: this.userId, accessToken: this.accessToken }; + + // jellyfinTrackId/sourceType are only known by this point, so the hash the constructor + // computed (before either was set) needs recomputing - otherwise every track collides. + // Must run before populating track fields below: generateHash() also seeds isFavorited + // from the local favoriteTracks list, which the server's own starred flag should take priority over. + track.generateHash(); + + 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); + + const audioStream = mediaSource?.MediaStreams?.find((stream) => stream.Type == "Audio"); + audioStream?.SampleRate && track.setSampleRate(audioStream.SampleRate); + audioStream?.BitDepth && track.setBitsPerSample(audioStream.BitDepth); + + 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); + } + + // Lives alongside the per-track .amf metadata cache files, one JSON file per Jellyfin server + private getCachePath() { + return window.path.join(this.amethyst.APPDATA_PATH || "", "/amethyst/Metadata Cache", `jellyfin-sync-${md5(this.url)}.amf`); + } + + private async readCache(): Promise { + try { + const raw = await window.fs.readFile(this.getCachePath(), "utf8"); + return JSON.parse(raw) as JellyfinCache; + } + catch (error) { + return null; + } + } + + private async writeCache(cache: JellyfinCache) { + try { + await window.fs.writeFile(this.getCachePath(), JSON.stringify(cache, null, 2)); + } + catch (error) { + console.error("Failed to write Jellyfin sync cache file, did you delete the 'Metadata Cache' folder?", error); + } + } + + public override register() { + } + + public override async unregister() { + this.scrobbleUnsubscribers.forEach((unsubscribe) => unsubscribe()); + this.scrobbleUnsubscribers = []; + if (this.scrobbleProgressTimer) clearInterval(this.scrobbleProgressTimer); + + try { + await window.fs.unlink(this.getCachePath()); + } + catch (error) {} + } +} diff --git a/src/renderer/logic/MediaSource/MediaSourceManager.ts b/src/renderer/logic/MediaSource/MediaSourceManager.ts index 9e97bdef..3ee1963a 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, savedSource.scrobble ?? true)); + } }); } @@ -54,6 +60,20 @@ export class MediaSourceManager { this.mediaSources.value.push(mediaSource); }; + public addJellyfinSource = async (url: string, username: string, password: string, scrobble = true) => { + const mediaSource = new JellyfinMediaSource(this.amethyst, url, username, password, scrobble); + + this.amethyst.state.settings.mediaSources.saveMediaSources.push({ + type: mediaSource.type, + url: mediaSource.url, + username: mediaSource.username, + password: mediaSource.password, + scrobble, + }); + // @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/SubsonicMediaSource.ts b/src/renderer/logic/MediaSource/SubsonicMediaSource.ts index 7a09d74e..db3d2d13 100644 --- a/src/renderer/logic/MediaSource/SubsonicMediaSource.ts +++ b/src/renderer/logic/MediaSource/SubsonicMediaSource.ts @@ -126,6 +126,15 @@ export class SubsonicMediaSource extends MediaSource { track.sourceType = MediaSourceType.Subsonic; track.subsonicTrackId = song.id; track.credentials = { username: this.username, password: this.password, url: this.url }; + + // subsonicTrackId/sourceType are only known by this point, so the hash the constructor + // computed (before either was set) needs recomputing as otherwise it falls back to hashing + // a URL that embeds the account password, which invalidates every track's identity the + // moment that password changes. Must run before setIsFavorite below: generateHash() also + // seeds isFavorited from the local favoriteTracks list, which the server's own starred flag + // should take priority over. + track.generateHash(); + track.setTitle(song.title); // low resolution cover art for performance 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/settings.ts b/src/renderer/logic/settings.ts index 665ae7f4..ed6b8686 100644 --- a/src/renderer/logic/settings.ts +++ b/src/renderer/logic/settings.ts @@ -174,7 +174,7 @@ const DEFAULT_INTEGRATION_SETTINGS = { const DEFAULT_MEDIA_SOURCE_SETTINGS = { mediaSources: { - saveMediaSources: [{}] as { type: MediaSourceType; path?: string; uuid?: string; url?: string; username?: string; password?: string }[], + saveMediaSources: [{}] as { type: MediaSourceType; path?: string; uuid?: string; url?: string; username?: string; password?: string; scrobble?: boolean }[], }, }; diff --git a/src/renderer/logic/track.ts b/src/renderer/logic/track.ts index edc25320..f2d3e38a 100644 --- a/src/renderer/logic/track.ts +++ b/src/renderer/logic/track.ts @@ -59,16 +59,26 @@ 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 = ""; public artists: string[] | undefined = undefined; public size: number = 0; public bitRate: number = 0; + public sampleRate: number = 0; + public bitsPerSample: number = 0; public discNumber: number = 0; public trackNumber: number = 0; public mimeType: string = ""; @@ -79,8 +89,25 @@ export class Track { this.generateHash(); } - private generateHash() { - this.uuid = md5(this.sourceType == MediaSourceType.Local ? `${this.getArtistsFormatted()}, ${this.getAlbum()}, ${this.getTitle()}, ${this.getFilename()}` : this.path); + /** + * Recomputes this track's identity hash. Safe (and cheap) to call again once a media + * source has finished populating sourceType/subsonicTrackId/jellyfinTrackId on a freshly + * constructed or upserted track - the constructor's own call runs before those are set, + * so remote sources need a follow-up call once their real identity fields are known. + */ + public generateHash() { + if (this.sourceType == MediaSourceType.Jellyfin && this.jellyfinTrackId) { + this.uuid = md5(`jellyfin:${this.jellyfinTrackId}`); + } + else if (this.sourceType == MediaSourceType.Subsonic && this.subsonicTrackId) { + this.uuid = md5(`subsonic:${this.subsonicTrackId}`); + } + else if (this.sourceType == MediaSourceType.Local) { + this.uuid = md5(`${this.getArtistsFormatted()}, ${this.getAlbum()}, ${this.getTitle()}, ${this.getFilename()}`); + } + else { + this.uuid = md5(this.path); + } this.isFavorited = favoriteTracks.value.includes(this.uuid); } @@ -102,6 +129,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 +146,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 +196,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 +213,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 +353,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) { @@ -413,6 +452,14 @@ export class Track { this.bitRate = t; } + public setSampleRate(t: number) { + this.sampleRate = t; + } + + public setBitsPerSample(t: number) { + this.bitsPerSample = t; + } + public setDiscNumber(t: number) { this.discNumber = t; } @@ -486,7 +533,7 @@ export class Track { } public getBitsPerSample() { - return this.getMetadata()?.format.bitsPerSample; + return this.bitsPerSample || this.getMetadata()?.format.bitsPerSample; } public getBitsPerSampleFormatted() { @@ -494,7 +541,7 @@ export class Track { } public getSampleRate() { - return this.getMetadata()?.format.sampleRate; + return this.sampleRate || this.getMetadata()?.format.sampleRate; } public getSampleRateFormatted() { diff --git a/src/renderer/views/Settings/MediaSourceSettings.vue b/src/renderer/views/Settings/MediaSourceSettings.vue index 18b1e775..673cfc23 100644 --- a/src/renderer/views/Settings/MediaSourceSettings.vue +++ b/src/renderer/views/Settings/MediaSourceSettings.vue @@ -5,12 +5,15 @@ import BaseInput from "@/components/BaseInput.vue"; import BaseForm from "@/components/BaseForm.vue"; import SettingsSetting from "@/components/settings/SettingsSetting.vue"; import ButtonInput from "@/components/v2/ButtonInput.vue"; +import ToggleSwitch from "@/components/v2/ToggleSwitch.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 +154,96 @@ const showAddServerForm = ref(false); + + + + + + + + + + + +