diff --git a/backend/plugins/jellyfin.plugin/src/index.ts b/backend/plugins/jellyfin.plugin/src/index.ts index 719b2b4..e1dc9d2 100644 --- a/backend/plugins/jellyfin.plugin/src/index.ts +++ b/backend/plugins/jellyfin.plugin/src/index.ts @@ -13,7 +13,14 @@ import { Subtitles, UserContext, VideoStream, + VideoStreamCandidate, } from 'plugins/plugin-types'; +import { + bitrateQualities, + formatSize, + formatTicksToTime, + getClosestBitrate, +} from './utils'; interface JellyfinSettings extends PluginSettings { apiKey: string; @@ -27,68 +34,14 @@ interface JellyfinUserContext extends UserContext { const JELLYFIN_DEVICE_ID = 'Reiverr Client'; -const bitrateQualities = [ - { - label: '4K - 120 Mbps', - bitrate: 120000000, - codec: undefined, - }, - { - label: '4K - 80 Mbps', - bitrate: 80000000, - codec: undefined, - }, - { - label: '1080p - 40 Mbps', - bitrate: 40000000, - codec: undefined, - }, - { - label: '1080p - 10 Mbps', - bitrate: 10000000, - codec: undefined, - }, - { - label: '720p - 8 Mbps', - bitrate: 8000000, - codec: undefined, - }, - { - label: '720p - 4 Mbps', - bitrate: 4000000, - codec: undefined, - }, - { - label: '480p - 3 Mbps', - bitrate: 3000000, - codec: undefined, - }, - { - label: '480p - 720 Kbps', - bitrate: 720000, - codec: undefined, - }, - { - label: '360p - 420 Kbps', - bitrate: 420000, - codec: undefined, - }, -]; - -function getClosestBitrate(qualities, bitrate) { - return qualities.reduce( - (prev, curr) => - Math.abs(curr.bitrate - bitrate) < Math.abs(prev.bitrate - bitrate) - ? curr - : prev, - qualities[0], - ); -} - @Injectable() export default class JellyfinPlugin implements SourcePlugin { name: string = 'jellyfin'; + private getProxyUrl(tmdbId: string) { + return `/api/movies/${tmdbId}/sources/${this.name}/stream/proxy`; + } + validateSettings: (settings: JellyfinSettings) => Promise<{ isValid: boolean; errors: Record; @@ -213,8 +166,25 @@ export default class JellyfinPlugin implements SourcePlugin { .then((res) => res.data.Items ?? []); } + async getMovieStreams( + tmdbId: string, + userContext: JellyfinUserContext, + config: PlaybackConfig = { + audioStreamIndex: undefined, + bitrate: undefined, + progress: undefined, + defaultLanguage: undefined, + deviceProfile: undefined, + }, + ): Promise { + return this.getMovieStream(tmdbId, '', userContext, config).then( + (stream) => [stream], + ); + } + async getMovieStream( tmdbId: string, + key: string, userContext: JellyfinUserContext, config: PlaybackConfig = { audioStreamIndex: undefined, @@ -226,7 +196,7 @@ export default class JellyfinPlugin implements SourcePlugin { ): Promise { const context = new PluginContext(userContext.settings, userContext.token); const items = await this.getLibraryItems(context); - const proxyUrl = `/api/movies/${tmdbId}/sources/${this.name}/stream`; + const proxyUrl = this.getProxyUrl(tmdbId); const movie = items.find((item) => item.ProviderIds?.Tmdb === tmdbId); @@ -288,22 +258,20 @@ export default class JellyfinPlugin implements SourcePlugin { index: s.Index, })) ?? []; - const qualities = [ + const qualities: VideoStream['qualities'] = [ ...bitrateQualities, { bitrate: mediasSource.Bitrate, label: 'Original', codec: undefined, + original: true, }, ].map((q, i) => ({ ...q, index: i, })); - const bitrate = Math.min( - maxStreamingBitrate, - movie.MediaSources[0].Bitrate, - ); + const bitrate = Math.min(maxStreamingBitrate, mediasSource.Bitrate); const subtitles: Subtitles[] = mediasSource.MediaStreams.filter( (s) => s.Type === 'Subtitle' && s.DeliveryUrl, @@ -315,6 +283,32 @@ export default class JellyfinPlugin implements SourcePlugin { })); return { + key: '', + title: movie.Name, + properties: [ + { + label: 'Video', + value: mediasSource.Bitrate || 0, + formatted: + mediasSource.MediaStreams.find((s) => s.Type === 'Video') + ?.DisplayTitle || 'Unknown', + }, + { + label: 'Size', + value: mediasSource.Size, + formatted: formatSize(mediasSource.Size), + }, + { + label: 'Filename', + value: mediasSource.Name, + formatted: undefined, + }, + { + label: 'Runtime', + value: mediasSource.RunTimeTicks, + formatted: formatTicksToTime(mediasSource.RunTimeTicks), + }, + ], audioStreamIndex: config.audioStreamIndex ?? mediasSource?.DefaultAudioStreamIndex ?? @@ -322,7 +316,7 @@ export default class JellyfinPlugin implements SourcePlugin { audioStreams, progress: config.progress ?? 0, qualities, - quality: getClosestBitrate(qualities, bitrate).index, + qualityIndex: getClosestBitrate(qualities, bitrate).index, subtitles, uri: playbackUri, directPlay: diff --git a/backend/plugins/jellyfin.plugin/src/utils.ts b/backend/plugins/jellyfin.plugin/src/utils.ts new file mode 100644 index 0000000..0b72715 --- /dev/null +++ b/backend/plugins/jellyfin.plugin/src/utils.ts @@ -0,0 +1,91 @@ +export function formatSize(size: number) { + const gbs = size / 1024 / 1024 / 1024; + const mbs = size / 1024 / 1024; + + if (gbs >= 1) { + return `${gbs.toFixed(2)} GB`; + } else { + return `${mbs.toFixed(2)} MB`; + } +} + +export const bitrateQualities = [ + { + label: '4K - 120 Mbps', + bitrate: 120000000, + codec: undefined, + original: false, + }, + { + label: '4K - 80 Mbps', + bitrate: 80000000, + codec: undefined, + original: false, + }, + { + label: '1080p - 40 Mbps', + bitrate: 40000000, + codec: undefined, + original: false, + }, + { + label: '1080p - 10 Mbps', + bitrate: 10000000, + codec: undefined, + original: false, + }, + { + label: '720p - 8 Mbps', + bitrate: 8000000, + codec: undefined, + original: false, + }, + { + label: '720p - 4 Mbps', + bitrate: 4000000, + codec: undefined, + original: false, + }, + { + label: '480p - 3 Mbps', + bitrate: 3000000, + codec: undefined, + original: false, + }, + { + label: '480p - 720 Kbps', + bitrate: 720000, + codec: undefined, + original: false, + }, + { + label: '360p - 420 Kbps', + bitrate: 420000, + codec: undefined, + original: false, + }, +]; + +export function getClosestBitrate(qualities, bitrate) { + return qualities.reduce( + (prev, curr) => + Math.abs(curr.bitrate - bitrate) < Math.abs(prev.bitrate - bitrate) + ? curr + : prev, + qualities[0], + ); +} + +export function formatTicksToTime(ticks: number) { + return formatMinutesToTime(ticks / 10_000_000 / 60); +} + +export function formatMinutesToTime(minutes: number) { + const days = Math.floor(minutes / 60 / 24); + const hours = Math.floor((minutes / 60) % 24); + const minutesLeft = Math.floor(minutes % 60); + + return `${days > 0 ? days + 'd ' : ''}${hours > 0 ? hours + 'h ' : ''}${ + days > 0 ? '' : minutesLeft + 'min' + }`; +} diff --git a/backend/plugins/plugin-types.d.ts b/backend/plugins/plugin-types.d.ts index ae857ed..933d8fc 100644 --- a/backend/plugins/plugin-types.d.ts +++ b/backend/plugins/plugin-types.d.ts @@ -40,6 +40,7 @@ export type Quality = { bitrate: number; label: string; codec: string | undefined; + original: boolean; }; export type Subtitles = { @@ -49,14 +50,26 @@ export type Subtitles = { codec: string | undefined; }; -export type VideoStream = { +export type VideoStreamProperty = { + label: string; + value: string | number; + formatted: string | undefined; +}; + +export type VideoStreamCandidate = { + key: string; + title: string; + properties: VideoStreamProperty[]; +}; + +export type VideoStream = VideoStreamCandidate & { uri: string; directPlay: boolean; progress: number; audioStreams: AudioStream[]; audioStreamIndex: number; qualities: Quality[]; - quality: number; + qualityIndex: number; subtitles: Subtitles[]; }; @@ -93,10 +106,24 @@ export interface SourcePlugin { getMovieStream: ( tmdbId: string, + key: string, context: UserContext, config?: PlaybackConfig, ) => Promise; + getMovieStreams: ( + tmdbId: string, + context: UserContext, + config?: PlaybackConfig, + ) => Promise; + + getMovieStream: ( + tmdbId: string, + context: UserContext, + key: string, + config?: PlaybackConfig, + ) => Promise; + getEpisodeStream: ( tmdbId: string, season: number, diff --git a/backend/src/source-plugins/source-plugins.controller.ts b/backend/src/source-plugins/source-plugins.controller.ts index bd01651..ff73f7c 100644 --- a/backend/src/source-plugins/source-plugins.controller.ts +++ b/backend/src/source-plugins/source-plugins.controller.ts @@ -21,12 +21,11 @@ import { Request, Response } from 'express'; import { Readable } from 'stream'; import { User } from 'src/users/user.entity'; import { UserSourcesService } from 'src/users/user-sources/user-sources.service'; -import { PluginSettingsTemplate } from 'plugins/plugin-types'; import { PlaybackConfigDto, PluginSettingsDto, PluginSettingsTemplateDto, - SourceListDto as VideoStreamListDto, + VideoStreamListDto, ValidationResponsekDto as ValidationResponseDto, VideoStreamDto, } from './source-plugins.dto'; @@ -97,48 +96,66 @@ export class SourcesController { } @ApiTags('movies') - @Get('movies/:tmdbId/sources') + @Get('movies/:tmdbId/sources/:sourceId/streams') @ApiOkResponse({ description: 'Movie sources', type: VideoStreamListDto, }) - async getMovieSources( + async getMovieStreams( @Param('tmdbId') tmdbId: string, + @Param('sourceId') sourceId: string, @GetUser() user: User, @GetAuthToken() token: string, ): Promise { - if (!user) { - throw new UnauthorizedException(); + const plugin = this.sourcesService.getPlugin(sourceId); + + if (!plugin) { + throw new NotFoundException('Plugin not found'); } - const plugins = await this.sourcesService.getPlugins(); - const sources: VideoStreamListDto['sources'] = {}; + const settings = this.userSourcesService.getSourceSettings(user, sourceId); - for (const pluginId in plugins) { - const plugin = plugins[pluginId]; - - if (!plugin) continue; - - const settings = this.userSourcesService.getSourceSettings( - user, - pluginId, - ); - - if (!settings) continue; - - const videoStream = await plugin.getMovieStream(tmdbId, { - settings, - token, - }); - - if (!videoStream) continue; - - sources[pluginId] = videoStream; + if (!settings) { + throw new BadRequestException('Source configuration not found'); } + const streams = await plugin.getMovieStreams(tmdbId, { + settings, + token, + }); + return { - sources, + streams, }; + + // const plugins = await this.sourcesService.getPlugins(); + // const streams: VideoStreamListDto['streams'] = []; + + // for (const pluginId in plugins) { + // const plugin = plugins[pluginId]; + + // if (!plugin) continue; + + // const settings = this.userSourcesService.getSourceSettings( + // user, + // pluginId, + // ); + + // if (!settings) continue; + + // const videoStream = await plugin.getMovieStreams(tmdbId, { + // settings, + // token, + // }); + + // if (!videoStream) continue; + + // streams[pluginId] = videoStream; + // } + + // return { + // streams, + // }; } @ApiTags('movies') @@ -148,14 +165,17 @@ export class SourcesController { type: VideoStreamDto, }) async getMovieStream( - @Param('sourceId') sourceId: string, @Param('tmdbId') tmdbId: string, + @Param('sourceId') sourceId: string, + @Query('key') key: string, @GetUser() user: User, @GetAuthToken() token: string, @Body() config: PlaybackConfigDto, ): Promise { - if (!user) { - throw new UnauthorizedException(); + const plugin = this.sourcesService.getPlugin(sourceId); + + if (!plugin) { + throw new NotFoundException('Plugin not found'); } const settings = this.userSourcesService.getSourceSettings(user, sourceId); @@ -164,8 +184,9 @@ export class SourcesController { throw new BadRequestException('Source configuration not found'); } - return this.sourcesService.getPlugin(sourceId)?.getMovieStream( + return plugin.getMovieStream( tmdbId, + key || '', { settings, token, @@ -175,7 +196,7 @@ export class SourcesController { } @ApiTags('movies') - @All('movies/:tmdbId/sources/:sourceId/stream/*') + @All('movies/:tmdbId/sources/:sourceId/stream/proxy/*') async getMovieStreamProxy( @Param() params: any, @Req() req: Request, diff --git a/backend/src/source-plugins/source-plugins.dto.ts b/backend/src/source-plugins/source-plugins.dto.ts index 24af0eb..0c637e6 100644 --- a/backend/src/source-plugins/source-plugins.dto.ts +++ b/backend/src/source-plugins/source-plugins.dto.ts @@ -14,6 +14,8 @@ import { Subtitles, ValidationResponse, VideoStream, + VideoStreamCandidate, + VideoStreamProperty, } from 'plugins/plugin-types'; import { DeviceProfileDto } from './device-profile.dto'; @@ -98,23 +100,6 @@ export class ValidationResponsekDto implements ValidationResponse { replace: Record; } -export class SourceDto { - @ApiProperty({ example: '/path/to/stream' }) - uri: string; -} - -export class SourceListDto { - @ApiProperty({ - type: 'object', - additionalProperties: { $ref: getSchemaPath(SourceDto) }, - example: { - source1: { uri: '/path/to/stream' }, - source2: { uri: '/path/to/other/stream' }, - }, - }) - sources: Record; -} - export class AudioStreamDto implements AudioStream { @ApiProperty() index: number; @@ -141,6 +126,9 @@ export class QualityDto implements Quality { @ApiProperty({ required: false }) codec: string | undefined; + + @ApiProperty() + original: boolean; } export class SubtitlesDto implements Subtitles { @@ -157,7 +145,34 @@ export class SubtitlesDto implements Subtitles { codec: string | undefined; } -export class VideoStreamDto implements VideoStream { +export class VideoStreamPropertyDto implements VideoStreamProperty { + @ApiProperty() + label: string; + + @ApiProperty({ + oneOf: [{ type: 'string' }, { type: 'number' }], + }) + value: string | number; + + @ApiProperty({ required: false }) + formatted: string | undefined; +} + +export class VideoStreamCandidateDto implements VideoStreamCandidate { + @ApiProperty() + key: string; + + @ApiProperty() + title: string; + + @ApiProperty({ type: [VideoStreamPropertyDto] }) + properties: VideoStreamPropertyDto[]; +} + +export class VideoStreamDto + extends VideoStreamCandidateDto + implements VideoStream +{ @ApiProperty() uri: string; @@ -177,7 +192,7 @@ export class VideoStreamDto implements VideoStream { qualities: QualityDto[]; @ApiProperty() - quality: number; + qualityIndex: number; @ApiProperty({ type: [SubtitlesDto] }) subtitles: SubtitlesDto[]; @@ -203,3 +218,10 @@ export class PlaybackConfigDto implements PlaybackConfig { @ApiPropertyOptional({ example: 'en', required: false }) defaultLanguage: string | undefined; } + +export class VideoStreamListDto { + @ApiProperty({ + type: [VideoStreamCandidateDto], + }) + streams: VideoStreamCandidateDto[]; +} diff --git a/src/lib/apis/reiverr/reiverr.openapi.ts b/src/lib/apis/reiverr/reiverr.openapi.ts index fe1490c..b8642f5 100644 --- a/src/lib/apis/reiverr/reiverr.openapi.ts +++ b/src/lib/apis/reiverr/reiverr.openapi.ts @@ -120,9 +120,20 @@ export interface ValidationResponsekDto { replace: Record; } -export interface SourceListDto { - /** @example {"source1":{"uri":"/path/to/stream"},"source2":{"uri":"/path/to/other/stream"}} */ - sources: Record; +export interface VideoStreamPropertyDto { + label: string; + value: string | number; + formatted?: string; +} + +export interface VideoStreamCandidateDto { + key: string; + title: string; + properties: VideoStreamPropertyDto[]; +} + +export interface VideoStreamListDto { + streams: VideoStreamCandidateDto[]; } export interface DirectPlayProfileDto { @@ -347,6 +358,7 @@ export interface QualityDto { bitrate: number; label: string; codec?: string; + original: boolean; } export interface SubtitlesDto { @@ -357,13 +369,16 @@ export interface SubtitlesDto { } export interface VideoStreamDto { + key: string; + title: string; + properties: VideoStreamPropertyDto[]; uri: string; directPlay: boolean; progress: number; audioStreams: AudioStreamDto[]; audioStreamIndex: number; qualities: QualityDto[]; - quality: number; + qualityIndex: number; subtitles: SubtitlesDto[]; } @@ -786,12 +801,12 @@ export class Api extends HttpClient - this.request({ - path: `/api/movies/${tmdbId}/sources`, + getMovieStreams: (tmdbId: string, sourceId: string, params: RequestParams = {}) => + this.request({ + path: `/api/movies/${tmdbId}/sources/${sourceId}/streams`, method: 'GET', format: 'json', ...params @@ -805,14 +820,18 @@ export class Api extends HttpClient this.request({ path: `/api/movies/${tmdbId}/sources/${sourceId}/stream`, method: 'POST', + query: query, body: data, type: ContentType.Json, format: 'json', @@ -824,11 +843,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'GET', ...params }), @@ -838,11 +857,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'POST', ...params }), @@ -852,11 +871,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'PUT', ...params }), @@ -866,11 +885,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'DELETE', ...params }), @@ -880,11 +899,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'PATCH', ...params }), @@ -894,11 +913,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'OPTIONS', ...params }), @@ -908,11 +927,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'HEAD', ...params }), @@ -922,11 +941,11 @@ export class Api extends HttpClient this.request({ - path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/*`, + path: `/api/movies/${tmdbId}/sources/${sourceId}/stream/proxy/*`, method: 'SEARCH', ...params }) diff --git a/src/lib/components/StackRouter/StackRouter.ts b/src/lib/components/StackRouter/StackRouter.ts index ea42a72..9ab8a53 100644 --- a/src/lib/components/StackRouter/StackRouter.ts +++ b/src/lib/components/StackRouter/StackRouter.ts @@ -5,7 +5,7 @@ import SeriesPage from '../SeriesPage/SeriesPage.svelte'; import EpisodePage from '../../pages/EpisodePage.svelte'; import { modalStack } from '../Modal/modal.store'; import MoviesHomePage from '../../pages/MoviesHomePage.svelte'; -import MoviePage from '../../pages/MoviePage.svelte'; +import MoviePage from '../../pages/MoviePage/MoviePage.svelte'; import LibraryPage from '../../pages/LibraryPage.svelte'; import SearchPage from '../../pages/SearchPage.svelte'; import PageNotFound from '../../pages/PageNotFound.svelte'; diff --git a/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte b/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte index 2963c0d..f647785 100644 --- a/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte +++ b/src/lib/components/VideoPlayer/MovieVideoPlayerModal.svelte @@ -11,6 +11,7 @@ export let tmdbId: string; export let sourceId: string; + export let key: string = ''; export let modalId: symbol; export let hidden: boolean = false; @@ -32,12 +33,19 @@ const refreshVideoStream = async (audioStreamIndex = 0) => { console.log('called2'); videoStreamP = reiverrApiNew.movies - .getMovieStream(sourceId, tmdbId, { - // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, - progress: 0, - audioStreamIndex, - deviceProfile: getDeviceProfile() as any - }) + .getMovieStream( + tmdbId, + sourceId, + { + key + }, + { + // bitrate: getQualities(1080)?.[0]?.maxBitrate || 10000000, + progress: 0, + audioStreamIndex, + deviceProfile: getDeviceProfile() as any + } + ) .then((r) => r.data) .then((d) => ({ ...d, diff --git a/src/lib/components/VideoPlayer/VideoPlayer.ts b/src/lib/components/VideoPlayer/VideoPlayer.ts index 58116b93..8781585 100644 --- a/src/lib/components/VideoPlayer/VideoPlayer.ts +++ b/src/lib/components/VideoPlayer/VideoPlayer.ts @@ -1,4 +1,4 @@ -import { writable } from 'svelte/store'; +import { get, writable } from 'svelte/store'; import { modalStack } from '../Modal/modal.store'; import { jellyfinItemsStore } from '../../stores/data.store'; import JellyfinVideoPlayerModal from './JellyfinVideoPlayerModal.svelte'; @@ -49,10 +49,17 @@ export type PlayerStateValue = typeof initialValue; function usePlayerState() { const store = writable(initialValue); - async function streamMovie(tmdbId: string, sourceId: string = '') { + async function streamMovie(tmdbId: string, sourceId: string = '', key: string = '') { if (!sourceId) { - const sources = await reiverrApiNew.movies.getMovieSources(tmdbId).then((r) => r.data); - sourceId = Object.keys(sources.sources)[0] || ''; + const streams = await Promise.all( + get(sources).map((s) => + reiverrApiNew.movies + .getMovieStreams(tmdbId, s.id) + .then((r) => ({ source: s, streams: r.data.streams })) + ) + ); + sourceId = streams?.[0]?.source.id || ''; + key = streams?.[0]?.streams?.[0]?.key || ''; } if (!sourceId) { @@ -63,7 +70,8 @@ function usePlayerState() { store.set({ visible: true, jellyfinId: tmdbId, sourceId }); modalStack.create(MovieVideoPlayerModal, { tmdbId, - sourceId + sourceId, + key }); } diff --git a/src/lib/pages/MoviePage.svelte b/src/lib/pages/MoviePage/MoviePage.svelte similarity index 79% rename from src/lib/pages/MoviePage.svelte rename to src/lib/pages/MoviePage/MoviePage.svelte index 3cc8c7c..e2fa774 100644 --- a/src/lib/pages/MoviePage.svelte +++ b/src/lib/pages/MoviePage/MoviePage.svelte @@ -1,8 +1,8 @@ @@ -173,8 +212,7 @@ {#if jellyfinItem} + + + + diff --git a/src/lib/stores/sources.store.ts b/src/lib/stores/sources.store.ts index 8d2c4a1..745546c 100644 --- a/src/lib/stores/sources.store.ts +++ b/src/lib/stores/sources.store.ts @@ -6,13 +6,13 @@ import { type Session, sessions } from './session.store'; import { user } from './user.store'; function useSources() { - const availableSources = derived(user, (user) => - user?.mediaSources?.filter((s) => s.enabled)?.map((s) => s.id) + const availableSources = derived( + user, + (user) => user?.mediaSources?.filter((s) => s.enabled)?.map((s) => ({ ...s })) ?? [] ); return { - subscribe: availableSources.subscribe, - + subscribe: availableSources.subscribe }; }